Merge branch 'develop' into img-max
This commit is contained in:
BIN
packages/backend/assets/tabler-badges/medal.png
Normal file
BIN
packages/backend/assets/tabler-badges/medal.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
@@ -37,8 +37,24 @@ const $redis: Provider = {
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $redisForPubsub: Provider = {
|
||||
provide: DI.redisForPubsub,
|
||||
const $redisForPub: Provider = {
|
||||
provide: DI.redisForPub,
|
||||
useFactory: (config) => {
|
||||
const redis = new Redis({
|
||||
port: config.redisForPubsub.port,
|
||||
host: config.redisForPubsub.host,
|
||||
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
|
||||
password: config.redisForPubsub.pass,
|
||||
keyPrefix: `${config.redisForPubsub.prefix}:`,
|
||||
db: config.redisForPubsub.db ?? 0,
|
||||
});
|
||||
return redis;
|
||||
},
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $redisForSub: Provider = {
|
||||
provide: DI.redisForSub,
|
||||
useFactory: (config) => {
|
||||
const redis = new Redis({
|
||||
port: config.redisForPubsub.port,
|
||||
@@ -57,14 +73,15 @@ const $redisForPubsub: Provider = {
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [RepositoryModule],
|
||||
providers: [$config, $db, $redis, $redisForPubsub],
|
||||
exports: [$config, $db, $redis, $redisForPubsub, RepositoryModule],
|
||||
providers: [$config, $db, $redis, $redisForPub, $redisForSub],
|
||||
exports: [$config, $db, $redis, $redisForPub, $redisForSub, RepositoryModule],
|
||||
})
|
||||
export class GlobalModule implements OnApplicationShutdown {
|
||||
constructor(
|
||||
@Inject(DI.db) private db: DataSource,
|
||||
@Inject(DI.redis) private redisClient: Redis.Redis,
|
||||
@Inject(DI.redisForPubsub) private redisForPubsub: Redis.Redis,
|
||||
@Inject(DI.redisForPub) private redisForPub: Redis.Redis,
|
||||
@Inject(DI.redisForSub) private redisForSub: Redis.Redis,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
@@ -79,7 +96,8 @@ export class GlobalModule implements OnApplicationShutdown {
|
||||
await Promise.all([
|
||||
this.db.destroy(),
|
||||
this.redisClient.disconnect(),
|
||||
this.redisForPubsub.disconnect(),
|
||||
this.redisForPub.disconnect(),
|
||||
this.redisForSub.disconnect(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@@ -27,8 +27,8 @@ export class AntennaService implements OnApplicationShutdown {
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.redisForPubsub)
|
||||
private redisForPubsub: Redis.Redis,
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
@@ -52,12 +52,12 @@ export class AntennaService implements OnApplicationShutdown {
|
||||
this.antennasFetched = false;
|
||||
this.antennas = [];
|
||||
|
||||
this.redisForPubsub.on('message', this.onRedisMessage);
|
||||
this.redisForSub.on('message', this.onRedisMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisForPubsub.off('message', this.onRedisMessage);
|
||||
this.redisForSub.off('message', this.onRedisMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -91,14 +91,24 @@ export class AntennaService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise<void> {
|
||||
this.redisClient.xadd(
|
||||
`antennaTimeline:${antenna.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
`${this.idService.parse(note.id).date.getTime()}-*`,
|
||||
'note', note.id);
|
||||
|
||||
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
|
||||
public async addNoteToAntennas(note: Note, noteUser: { id: User['id']; username: string; host: string | null; }): Promise<void> {
|
||||
const antennas = await this.getAntennas();
|
||||
const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
|
||||
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
for (const antenna of matchedAntennas) {
|
||||
redisPipeline.xadd(
|
||||
`antennaTimeline:${antenna.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
|
||||
}
|
||||
|
||||
redisPipeline.exec();
|
||||
}
|
||||
|
||||
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
||||
|
@@ -27,8 +27,8 @@ export class CacheService implements OnApplicationShutdown {
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.redisForPubsub)
|
||||
private redisForPubsub: Redis.Redis,
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
@@ -116,7 +116,7 @@ export class CacheService implements OnApplicationShutdown {
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
});
|
||||
|
||||
this.redisForPubsub.on('message', this.onMessage);
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -167,6 +167,6 @@ export class CacheService implements OnApplicationShutdown {
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisForPubsub.off('message', this.onMessage);
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
}
|
||||
}
|
||||
|
@@ -43,12 +43,8 @@ export class CustomEmojiService {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60 * 3, // 3m
|
||||
fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))),
|
||||
toRedisConverter: (value) => JSON.stringify(value.values()),
|
||||
fromRedisConverter: (value) => {
|
||||
// 原因不明だが配列以外が入ってくることがあるため
|
||||
if (!Array.isArray(JSON.parse(value))) return undefined;
|
||||
return new Map(JSON.parse(value).map((x: Emoji) => [x.name, x]));
|
||||
}, // TODO: Date型の変換
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value.values())),
|
||||
fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換
|
||||
});
|
||||
}
|
||||
|
||||
@@ -271,16 +267,7 @@ export class CustomEmojiService {
|
||||
const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull);
|
||||
|
||||
if (emoji == null) return null;
|
||||
|
||||
const isLocal = emoji.host == null;
|
||||
const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||
const url = isLocal
|
||||
? emojiUrl
|
||||
: this.config.proxyRemoteFiles
|
||||
? `${this.config.mediaProxy}/emoji.webp?${query({ url: emojiUrl })}`
|
||||
: emojiUrl;
|
||||
|
||||
return url;
|
||||
return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -26,8 +26,8 @@ export class GlobalEventService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.redisForPub)
|
||||
private redisForPub: Redis.Redis,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export class GlobalEventService {
|
||||
{ type: type, body: null } :
|
||||
{ type: type, body: value };
|
||||
|
||||
this.redisClient.publish(this.config.host, JSON.stringify({
|
||||
this.redisForPub.publish(this.config.host, JSON.stringify({
|
||||
channel: channel,
|
||||
message: message,
|
||||
}));
|
||||
|
@@ -14,8 +14,8 @@ export class MetaService implements OnApplicationShutdown {
|
||||
private intervalId: NodeJS.Timer;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisForPubsub)
|
||||
private redisForPubsub: Redis.Redis,
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
@@ -33,7 +33,7 @@ export class MetaService implements OnApplicationShutdown {
|
||||
}, 1000 * 60 * 5);
|
||||
}
|
||||
|
||||
this.redisForPubsub.on('message', this.onMessage);
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -122,6 +122,6 @@ export class MetaService implements OnApplicationShutdown {
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
clearInterval(this.intervalId);
|
||||
this.redisForPubsub.off('message', this.onMessage);
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
}
|
||||
}
|
||||
|
@@ -329,7 +329,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
this.redisClient.xadd(
|
||||
`channelTimeline:${data.channel.id}`,
|
||||
'MAXLEN', '~', '1000',
|
||||
`${this.idService.parse(note.id).date.getTime()}-*`,
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
|
||||
@@ -493,14 +493,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
}
|
||||
});
|
||||
|
||||
// Antenna
|
||||
for (const antenna of (await this.antennaService.getAntennas())) {
|
||||
this.antennaService.checkHitAntenna(antenna, note, user).then(hit => {
|
||||
if (hit) {
|
||||
this.antennaService.addNoteToAntenna(antenna, note, user);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.antennaService.addNoteToAntennas(note, user);
|
||||
|
||||
if (data.reply) {
|
||||
this.saveReply(data.reply, note);
|
||||
|
@@ -66,6 +66,7 @@ export class NotificationService implements OnApplicationShutdown {
|
||||
@bindThis
|
||||
private postReadAllNotifications(userId: User['id']) {
|
||||
this.globalEventService.publishMainStream(userId, 'readAllNotifications');
|
||||
this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -99,7 +100,7 @@ export class NotificationService implements OnApplicationShutdown {
|
||||
const redisIdPromise = this.redisClient.xadd(
|
||||
`notificationTimeline:${notifieeId}`,
|
||||
'MAXLEN', '~', '300',
|
||||
`${this.idService.parse(notification.id).date.getTime()}-*`,
|
||||
'*',
|
||||
'data', JSON.stringify(notification));
|
||||
|
||||
const packed = await this.notificationEntityService.pack(notification, notifieeId, {});
|
||||
|
@@ -1,12 +1,14 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import push from 'web-push';
|
||||
import Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { Packed } from '@/misc/json-schema';
|
||||
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||
import type { SwSubscriptionsRepository } from '@/models/index.js';
|
||||
import type { SwSubscription, SwSubscriptionsRepository } from '@/models/index.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
|
||||
// Defined also packages/sw/types.ts#L13
|
||||
type PushNotificationsTypes = {
|
||||
@@ -15,6 +17,7 @@ type PushNotificationsTypes = {
|
||||
antenna: { id: string, name: string };
|
||||
note: Packed<'Note'>;
|
||||
};
|
||||
'readAllNotifications': undefined;
|
||||
};
|
||||
|
||||
// Reduce length because push message servers have character limits
|
||||
@@ -40,15 +43,27 @@ function truncateBody<T extends keyof PushNotificationsTypes>(type: T, body: Pus
|
||||
|
||||
@Injectable()
|
||||
export class PushNotificationService {
|
||||
private subscriptionsCache: RedisKVCache<SwSubscription[]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.swSubscriptionsRepository)
|
||||
private swSubscriptionsRepository: SwSubscriptionsRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
this.subscriptionsCache = new RedisKVCache<SwSubscription[]>(this.redisClient, 'userSwSubscriptions', {
|
||||
lifetime: 1000 * 60 * 60 * 1, // 1h
|
||||
memoryCacheLifetime: 1000 * 60 * 3, // 3m
|
||||
fetcher: (key) => this.swSubscriptionsRepository.findBy({ userId: key }),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => JSON.parse(value),
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -62,12 +77,13 @@ export class PushNotificationService {
|
||||
meta.swPublicKey,
|
||||
meta.swPrivateKey);
|
||||
|
||||
// Fetch
|
||||
const subscriptions = await this.swSubscriptionsRepository.findBy({
|
||||
userId: userId,
|
||||
});
|
||||
const subscriptions = await this.subscriptionsCache.fetch(userId);
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
if ([
|
||||
'readAllNotifications',
|
||||
].includes(type) && !subscription.sendReadMessage) continue;
|
||||
|
||||
const pushSubscription = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
|
@@ -3,7 +3,7 @@ import Bull from 'bull';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from '../queue/types.js';
|
||||
import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData, DbJobMap } from '../queue/types.js';
|
||||
|
||||
function q<T>(config: Config, name: string, limitPerSec = -1) {
|
||||
return new Bull<T>(name, {
|
||||
@@ -41,7 +41,8 @@ export type SystemQueue = Bull.Queue<Record<string, unknown>>;
|
||||
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
|
||||
export type DeliverQueue = Bull.Queue<DeliverJobData>;
|
||||
export type InboxQueue = Bull.Queue<InboxJobData>;
|
||||
export type DbQueue = Bull.Queue<DbJobData>;
|
||||
export type DbQueue = Bull.Queue<DbJobData<keyof DbJobMap>>;
|
||||
export type RelationshipQueue = Bull.Queue<RelationshipJobData>;
|
||||
export type ObjectStorageQueue = Bull.Queue<ObjectStorageJobData>;
|
||||
export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>;
|
||||
|
||||
@@ -75,6 +76,12 @@ const $db: Provider = {
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $relationship: Provider = {
|
||||
provide: 'queue:relationship',
|
||||
useFactory: (config: Config) => q(config, 'relationship'),
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $objectStorage: Provider = {
|
||||
provide: 'queue:objectStorage',
|
||||
useFactory: (config: Config) => q(config, 'objectStorage'),
|
||||
@@ -96,6 +103,7 @@ const $webhookDeliver: Provider = {
|
||||
$deliver,
|
||||
$inbox,
|
||||
$db,
|
||||
$relationship,
|
||||
$objectStorage,
|
||||
$webhookDeliver,
|
||||
],
|
||||
@@ -105,6 +113,7 @@ const $webhookDeliver: Provider = {
|
||||
$deliver,
|
||||
$inbox,
|
||||
$db,
|
||||
$relationship,
|
||||
$objectStorage,
|
||||
$webhookDeliver,
|
||||
],
|
||||
|
@@ -6,9 +6,10 @@ import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js';
|
||||
import type { ThinUser } from '../queue/types.js';
|
||||
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js';
|
||||
import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js';
|
||||
import type httpSignature from '@peertube/http-signature';
|
||||
import Bull from 'bull';
|
||||
|
||||
@Injectable()
|
||||
export class QueueService {
|
||||
@@ -21,6 +22,7 @@ export class QueueService {
|
||||
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
|
||||
@Inject('queue:inbox') public inboxQueue: InboxQueue,
|
||||
@Inject('queue:db') public dbQueue: DbQueue,
|
||||
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
|
||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
|
||||
) {}
|
||||
@@ -56,7 +58,7 @@ export class QueueService {
|
||||
activity: activity,
|
||||
signature,
|
||||
};
|
||||
|
||||
|
||||
return this.inboxQueue.add(data, {
|
||||
attempts: this.config.inboxJobMaxAttempts ?? 8,
|
||||
timeout: 5 * 60 * 1000, // 5min
|
||||
@@ -71,7 +73,7 @@ export class QueueService {
|
||||
@bindThis
|
||||
public createDeleteDriveFilesJob(user: ThinUser) {
|
||||
return this.dbQueue.add('deleteDriveFiles', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
@@ -81,7 +83,7 @@ export class QueueService {
|
||||
@bindThis
|
||||
public createExportCustomEmojisJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportCustomEmojis', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
@@ -91,7 +93,7 @@ export class QueueService {
|
||||
@bindThis
|
||||
public createExportNotesJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportNotes', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
@@ -101,7 +103,7 @@ export class QueueService {
|
||||
@bindThis
|
||||
public createExportFavoritesJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportFavorites', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
@@ -111,7 +113,7 @@ export class QueueService {
|
||||
@bindThis
|
||||
public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) {
|
||||
return this.dbQueue.add('exportFollowing', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
excludeMuting,
|
||||
excludeInactive,
|
||||
}, {
|
||||
@@ -123,7 +125,7 @@ export class QueueService {
|
||||
@bindThis
|
||||
public createExportMuteJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportMuting', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
@@ -133,7 +135,7 @@ export class QueueService {
|
||||
@bindThis
|
||||
public createExportBlockingJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportBlocking', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
@@ -143,7 +145,7 @@ export class QueueService {
|
||||
@bindThis
|
||||
public createExportUserListsJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportUserLists', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
@@ -153,7 +155,7 @@ export class QueueService {
|
||||
@bindThis
|
||||
public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importFollowing', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
@@ -161,10 +163,16 @@ export class QueueService {
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createImportFollowingToDbJob(user: ThinUser, targets: string[]) {
|
||||
const jobs = targets.map(rel => this.generateToDbJobData('importFollowingToDb', { user, target: rel }));
|
||||
return this.dbQueue.addBulk(jobs);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importMuting', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
@@ -175,7 +183,7 @@ export class QueueService {
|
||||
@bindThis
|
||||
public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importBlocking', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
@@ -183,10 +191,32 @@ export class QueueService {
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createImportBlockingToDbJob(user: ThinUser, targets: string[]) {
|
||||
const jobs = targets.map(rel => this.generateToDbJobData('importBlockingToDb', { user, target: rel }));
|
||||
return this.dbQueue.addBulk(jobs);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private generateToDbJobData<T extends 'importFollowingToDb' | 'importBlockingToDb', D extends DbJobData<T>>(name: T, data: D): {
|
||||
name: string,
|
||||
data: D,
|
||||
opts: Bull.JobOptions,
|
||||
} {
|
||||
return {
|
||||
name,
|
||||
data,
|
||||
opts: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importUserLists', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
@@ -197,7 +227,7 @@ export class QueueService {
|
||||
@bindThis
|
||||
public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importCustomEmojis', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
@@ -208,7 +238,7 @@ export class QueueService {
|
||||
@bindThis
|
||||
public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) {
|
||||
return this.dbQueue.add('deleteAccount', {
|
||||
user: user,
|
||||
user: { id: user.id },
|
||||
soft: opts.soft,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
@@ -216,6 +246,51 @@ export class QueueService {
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createFollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string, silent?: boolean }[]) {
|
||||
const jobs = followings.map(rel => this.generateRelationshipJobData('follow', rel));
|
||||
return this.relationshipQueue.addBulk(jobs);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[]) {
|
||||
const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel));
|
||||
return this.relationshipQueue.addBulk(jobs);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) {
|
||||
const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel));
|
||||
return this.relationshipQueue.addBulk(jobs);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createUnblockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) {
|
||||
const jobs = blockings.map(rel => this.generateRelationshipJobData('unblock', rel));
|
||||
return this.relationshipQueue.addBulk(jobs);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData): {
|
||||
name: string,
|
||||
data: RelationshipJobData,
|
||||
opts: Bull.JobOptions,
|
||||
} {
|
||||
return {
|
||||
name,
|
||||
data: {
|
||||
from: { id: data.from.id },
|
||||
to: { id: data.to.id },
|
||||
silent: data.silent,
|
||||
requestId: data.requestId,
|
||||
},
|
||||
opts: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createDeleteObjectStorageFileJob(key: string) {
|
||||
return this.objectStorageQueue.add('deleteFile', {
|
||||
@@ -246,7 +321,7 @@ export class QueueService {
|
||||
createdAt: Date.now(),
|
||||
eventId: uuid(),
|
||||
};
|
||||
|
||||
|
||||
return this.webhookDeliverQueue.add(data, {
|
||||
attempts: 4,
|
||||
timeout: 1 * 60 * 1000, // 1min
|
||||
@@ -264,7 +339,7 @@ export class QueueService {
|
||||
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
||||
});
|
||||
this.deliverQueue.clean(0, 'delayed');
|
||||
|
||||
|
||||
this.inboxQueue.once('cleaned', (jobs, status) => {
|
||||
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
||||
});
|
||||
|
@@ -64,8 +64,8 @@ export class RoleService implements OnApplicationShutdown {
|
||||
public static NotAssignedError = class extends Error {};
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisForPubsub)
|
||||
private redisForPubsub: Redis.Redis,
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
@@ -87,7 +87,7 @@ export class RoleService implements OnApplicationShutdown {
|
||||
this.rolesCache = new MemorySingleCache<Role[]>(1000 * 60 * 60 * 1);
|
||||
this.roleAssignmentByUserIdCache = new MemoryKVCache<RoleAssignment[]>(1000 * 60 * 60 * 1);
|
||||
|
||||
this.redisForPubsub.on('message', this.onMessage);
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -400,6 +400,6 @@ export class RoleService implements OnApplicationShutdown {
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisForPubsub.off('message', this.onMessage);
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
}
|
||||
}
|
||||
|
@@ -24,7 +24,7 @@ export class UserBlockingService implements OnModuleInit {
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
|
||||
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
@@ -54,12 +54,12 @@ export class UserBlockingService implements OnModuleInit {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async block(blocker: User, blockee: User) {
|
||||
public async block(blocker: User, blockee: User, silent = false) {
|
||||
await Promise.all([
|
||||
this.cancelRequest(blocker, blockee),
|
||||
this.cancelRequest(blockee, blocker),
|
||||
this.userFollowingService.unfollow(blocker, blockee),
|
||||
this.userFollowingService.unfollow(blockee, blocker),
|
||||
this.cancelRequest(blocker, blockee, silent),
|
||||
this.cancelRequest(blockee, blocker, silent),
|
||||
this.userFollowingService.unfollow(blocker, blockee, silent),
|
||||
this.userFollowingService.unfollow(blockee, blocker, silent),
|
||||
this.removeFromList(blockee, blocker),
|
||||
]);
|
||||
|
||||
@@ -89,7 +89,7 @@ export class UserBlockingService implements OnModuleInit {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async cancelRequest(follower: User, followee: User) {
|
||||
private async cancelRequest(follower: User, followee: User, silent = false) {
|
||||
const request = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
@@ -110,7 +110,7 @@ export class UserBlockingService implements OnModuleInit {
|
||||
}).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed));
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
if (this.userEntityService.isLocalUser(follower) && !silent) {
|
||||
this.userEntityService.pack(followee, follower, {
|
||||
detail: true,
|
||||
}).then(async packed => {
|
||||
|
@@ -43,7 +43,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@@ -79,7 +79,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise<void> {
|
||||
public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string, silent = false): Promise<void> {
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: _follower.id }),
|
||||
this.usersRepository.findOneByOrFail({ id: _followee.id }),
|
||||
@@ -139,7 +139,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
await this.insertFollowingDoc(followee, follower);
|
||||
await this.insertFollowingDoc(followee, follower, silent);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
|
||||
@@ -155,6 +155,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||
follower: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
|
||||
},
|
||||
silent = false,
|
||||
): Promise<void> {
|
||||
if (follower.id === followee.id) return;
|
||||
|
||||
@@ -233,7 +234,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||
this.perUserFollowingChart.update(follower, followee, true);
|
||||
|
||||
// Publish follow event
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
if (this.userEntityService.isLocalUser(follower) && !silent) {
|
||||
this.userEntityService.pack(followee.id, follower, {
|
||||
detail: true,
|
||||
}).then(async packed => {
|
||||
|
@@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ProxyAccountService } from '@/core/ProxyAccountService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserListService {
|
||||
@@ -29,6 +30,7 @@ export class UserListService {
|
||||
private roleService: RoleService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private proxyAccountService: ProxyAccountService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -47,14 +49,14 @@ export class UserListService {
|
||||
userId: target.id,
|
||||
userListId: list.id,
|
||||
} as UserListJoining);
|
||||
|
||||
|
||||
this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
|
||||
|
||||
|
||||
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
|
||||
if (this.userEntityService.isRemoteUser(target)) {
|
||||
const proxy = await this.proxyAccountService.fetch();
|
||||
if (proxy) {
|
||||
this.userFollowingService.follow(proxy, target);
|
||||
this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -13,14 +13,14 @@ export class WebhookService implements OnApplicationShutdown {
|
||||
private webhooks: Webhook[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisForPubsub)
|
||||
private redisForPubsub: Redis.Redis,
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.webhooksRepository)
|
||||
private webhooksRepository: WebhooksRepository,
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
this.redisForPubsub.on('message', this.onMessage);
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -82,6 +82,6 @@ export class WebhookService implements OnApplicationShutdown {
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisForPubsub.off('message', this.onMessage);
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,8 @@ export const DI = {
|
||||
config: Symbol('config'),
|
||||
db: Symbol('db'),
|
||||
redis: Symbol('redis'),
|
||||
redisForPubsub: Symbol('redisForPubsub'),
|
||||
redisForPub: Symbol('redisForPub'),
|
||||
redisForSub: Symbol('redisForSub'),
|
||||
|
||||
//#region Repositories
|
||||
usersRepository: Symbol('usersRepository'),
|
||||
|
@@ -8,7 +8,7 @@ export class RedisKVCache<T> {
|
||||
private memoryCache: MemoryKVCache<T>;
|
||||
private fetcher: (key: string) => Promise<T>;
|
||||
private toRedisConverter: (value: T) => string;
|
||||
private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache
|
||||
private fromRedisConverter: (value: string) => T;
|
||||
|
||||
constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: {
|
||||
lifetime: RedisKVCache<T>['lifetime'];
|
||||
@@ -92,7 +92,7 @@ export class RedisSingleCache<T> {
|
||||
private memoryCache: MemorySingleCache<T>;
|
||||
private fetcher: () => Promise<T>;
|
||||
private toRedisConverter: (value: T) => string;
|
||||
private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache
|
||||
private fromRedisConverter: (value: string) => T;
|
||||
|
||||
constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: {
|
||||
lifetime: RedisSingleCache<T>['lifetime'];
|
||||
|
@@ -52,8 +52,10 @@ export class DbQueueProcessorsService {
|
||||
q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done));
|
||||
q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done));
|
||||
q.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done));
|
||||
q.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job));
|
||||
q.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done));
|
||||
q.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done));
|
||||
q.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job));
|
||||
q.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done));
|
||||
q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done));
|
||||
q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job));
|
||||
|
@@ -4,6 +4,7 @@ import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { QueueLoggerService } from './QueueLoggerService.js';
|
||||
import { QueueProcessorService } from './QueueProcessorService.js';
|
||||
import { DbQueueProcessorsService } from './DbQueueProcessorsService.js';
|
||||
import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js';
|
||||
import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js';
|
||||
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
|
||||
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
|
||||
@@ -32,6 +33,7 @@ import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessor
|
||||
import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js';
|
||||
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
|
||||
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
|
||||
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -61,9 +63,11 @@ import { ExportFavoritesProcessorService } from './processors/ExportFavoritesPro
|
||||
DeleteAccountProcessorService,
|
||||
DeleteFileProcessorService,
|
||||
CleanRemoteFilesProcessorService,
|
||||
RelationshipProcessorService,
|
||||
SystemQueueProcessorsService,
|
||||
ObjectStorageQueueProcessorsService,
|
||||
DbQueueProcessorsService,
|
||||
RelationshipQueueProcessorsService,
|
||||
WebhookDeliverProcessorService,
|
||||
EndedPollNotificationProcessorService,
|
||||
DeliverProcessorService,
|
||||
|
@@ -13,6 +13,7 @@ import { EndedPollNotificationProcessorService } from './processors/EndedPollNot
|
||||
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
|
||||
import { InboxProcessorService } from './processors/InboxProcessorService.js';
|
||||
import { QueueLoggerService } from './QueueLoggerService.js';
|
||||
import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js';
|
||||
|
||||
@Injectable()
|
||||
export class QueueProcessorService {
|
||||
@@ -27,6 +28,7 @@ export class QueueProcessorService {
|
||||
private systemQueueProcessorsService: SystemQueueProcessorsService,
|
||||
private objectStorageQueueProcessorsService: ObjectStorageQueueProcessorsService,
|
||||
private dbQueueProcessorsService: DbQueueProcessorsService,
|
||||
private relationshipQueueProcessorsService: RelationshipQueueProcessorsService,
|
||||
private webhookDeliverProcessorService: WebhookDeliverProcessorService,
|
||||
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
|
||||
private deliverProcessorService: DeliverProcessorService,
|
||||
@@ -52,14 +54,15 @@ export class QueueProcessorService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const systemLogger = this.logger.createSubLogger('system');
|
||||
const deliverLogger = this.logger.createSubLogger('deliver');
|
||||
const webhookLogger = this.logger.createSubLogger('webhook');
|
||||
const inboxLogger = this.logger.createSubLogger('inbox');
|
||||
const dbLogger = this.logger.createSubLogger('db');
|
||||
const relationshipLogger = this.logger.createSubLogger('relationship');
|
||||
const objectStorageLogger = this.logger.createSubLogger('objectStorage');
|
||||
|
||||
|
||||
this.queueService.systemQueue
|
||||
.on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`))
|
||||
.on('active', (job) => systemLogger.debug(`active id=${job.id}`))
|
||||
@@ -67,7 +70,7 @@ export class QueueProcessorService {
|
||||
.on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
|
||||
.on('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) }))
|
||||
.on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`));
|
||||
|
||||
|
||||
this.queueService.deliverQueue
|
||||
.on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`))
|
||||
.on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
|
||||
@@ -75,7 +78,7 @@ export class QueueProcessorService {
|
||||
.on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`))
|
||||
.on('error', (job: any, err: Error) => deliverLogger.error(`error ${err}`, { job, e: renderError(err) }))
|
||||
.on('stalled', (job) => deliverLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`));
|
||||
|
||||
|
||||
this.queueService.inboxQueue
|
||||
.on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`))
|
||||
.on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`))
|
||||
@@ -83,7 +86,7 @@ export class QueueProcessorService {
|
||||
.on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`, { job, e: renderError(err) }))
|
||||
.on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) }))
|
||||
.on('stalled', (job) => inboxLogger.warn(`stalled ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`));
|
||||
|
||||
|
||||
this.queueService.dbQueue
|
||||
.on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`))
|
||||
.on('active', (job) => dbLogger.debug(`active id=${job.id}`))
|
||||
@@ -91,7 +94,15 @@ export class QueueProcessorService {
|
||||
.on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
|
||||
.on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) }))
|
||||
.on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`));
|
||||
|
||||
|
||||
this.queueService.relationshipQueue
|
||||
.on('waiting', (jobId) => relationshipLogger.debug(`waiting id=${jobId}`))
|
||||
.on('active', (job) => relationshipLogger.debug(`active id=${job.id}`))
|
||||
.on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`))
|
||||
.on('failed', (job, err) => relationshipLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
|
||||
.on('error', (job: any, err: Error) => relationshipLogger.error(`error ${err}`, { job, e: renderError(err) }))
|
||||
.on('stalled', (job) => relationshipLogger.warn(`stalled id=${job.id}`));
|
||||
|
||||
this.queueService.objectStorageQueue
|
||||
.on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`))
|
||||
.on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`))
|
||||
@@ -99,7 +110,7 @@ export class QueueProcessorService {
|
||||
.on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
|
||||
.on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) }))
|
||||
.on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`));
|
||||
|
||||
|
||||
this.queueService.webhookDeliverQueue
|
||||
.on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`))
|
||||
.on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
|
||||
@@ -107,26 +118,27 @@ export class QueueProcessorService {
|
||||
.on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`))
|
||||
.on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) }))
|
||||
.on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`));
|
||||
|
||||
|
||||
this.queueService.deliverQueue.process(this.config.deliverJobConcurrency ?? 128, (job) => this.deliverProcessorService.process(job));
|
||||
this.queueService.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job));
|
||||
this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done));
|
||||
this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job));
|
||||
this.dbQueueProcessorsService.start(this.queueService.dbQueue);
|
||||
this.relationshipQueueProcessorsService.start(this.queueService.relationshipQueue);
|
||||
this.objectStorageQueueProcessorsService.start(this.queueService.objectStorageQueue);
|
||||
|
||||
|
||||
this.queueService.systemQueue.add('tickCharts', {
|
||||
}, {
|
||||
repeat: { cron: '55 * * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
|
||||
this.queueService.systemQueue.add('resyncCharts', {
|
||||
}, {
|
||||
repeat: { cron: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
|
||||
this.queueService.systemQueue.add('cleanCharts', {
|
||||
}, {
|
||||
repeat: { cron: '0 0 * * *' },
|
||||
@@ -138,19 +150,19 @@ export class QueueProcessorService {
|
||||
repeat: { cron: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
|
||||
this.queueService.systemQueue.add('clean', {
|
||||
}, {
|
||||
repeat: { cron: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
|
||||
this.queueService.systemQueue.add('checkExpiredMutings', {
|
||||
}, {
|
||||
repeat: { cron: '*/5 * * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
|
||||
this.systemQueueProcessorsService.start(this.queueService.systemQueue);
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,26 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
|
||||
import type Bull from 'bull';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
||||
@Injectable()
|
||||
export class RelationshipQueueProcessorsService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private relationshipProcessorService: RelationshipProcessorService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public start(q: Bull.Queue): void {
|
||||
const maxJobs = (this.config.deliverJobConcurrency ?? 128) / 4; // conservative?
|
||||
q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job));
|
||||
q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job));
|
||||
q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job));
|
||||
q.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job));
|
||||
}
|
||||
}
|
@@ -7,7 +7,7 @@ import type Logger from '@/logger.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { DbUserJobData } from '../types.js';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -31,7 +31,7 @@ export class DeleteDriveFilesProcessorService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
|
||||
this.logger.info(`Deleting drive files of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
|
@@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { DbUserJobData } from '../types.js';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -36,7 +36,7 @@ export class ExportBlockingProcessorService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
|
||||
this.logger.info(`Exporting blocking of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
|
@@ -13,7 +13,7 @@ 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 type { DbJobDataWithUser } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ExportFavoritesProcessorService {
|
||||
@@ -42,7 +42,7 @@ export class ExportFavoritesProcessorService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
|
||||
this.logger.info(`Exporting favorites of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
|
@@ -12,7 +12,7 @@ import type { Following } from '@/models/entities/Following.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { DbUserJobData } from '../types.js';
|
||||
import type { DbExportFollowingData } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -40,7 +40,7 @@ export class ExportFollowingProcessorService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbExportFollowingData>, done: () => void): Promise<void> {
|
||||
this.logger.info(`Exporting following of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
|
@@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { DbUserJobData } from '../types.js';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -39,7 +39,7 @@ export class ExportMutingProcessorService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
|
||||
this.logger.info(`Exporting muting of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
|
@@ -13,7 +13,7 @@ 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 type { DbJobDataWithUser } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ExportNotesProcessorService {
|
||||
@@ -39,7 +39,7 @@ export class ExportNotesProcessorService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
|
||||
this.logger.info(`Exporting notes of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
|
@@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { DbUserJobData } from '../types.js';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -39,7 +39,7 @@ export class ExportUserListsProcessorService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbUserJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
|
||||
this.logger.info(`Exporting user lists of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
|
@@ -1,38 +1,31 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository, BlockingsRepository, DriveFilesRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { UsersRepository, DriveFilesRepository } from '@/models/index.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { DbUserImportJobData } from '../types.js';
|
||||
import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ImportBlockingProcessorService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private queueService: QueueService,
|
||||
private utilityService: UtilityService,
|
||||
private userBlockingService: UserBlockingService,
|
||||
private remoteUserResolveService: RemoteUserResolveService,
|
||||
private downloadService: DownloadService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
@@ -59,46 +52,50 @@ export class ImportBlockingProcessorService {
|
||||
}
|
||||
|
||||
const csv = await this.downloadService.downloadTextFile(file.url);
|
||||
const targets = csv.trim().split('\n');
|
||||
this.queueService.createImportBlockingToDbJob({ id: user.id }, targets);
|
||||
|
||||
let linenum = 0;
|
||||
|
||||
for (const line of csv.trim().split('\n')) {
|
||||
linenum++;
|
||||
|
||||
try {
|
||||
const acct = line.split(',')[0].trim();
|
||||
const { username, host } = Acct.parse(acct);
|
||||
|
||||
let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
usernameLower: username.toLowerCase(),
|
||||
}) : await this.usersRepository.findOneBy({
|
||||
host: this.utilityService.toPuny(host!),
|
||||
usernameLower: username.toLowerCase(),
|
||||
});
|
||||
|
||||
if (host == null && target == null) continue;
|
||||
|
||||
if (target == null) {
|
||||
target = await this.remoteUserResolveService.resolveUser(username, host);
|
||||
}
|
||||
|
||||
if (target == null) {
|
||||
throw `cannot resolve user: @${username}@${host}`;
|
||||
}
|
||||
|
||||
// skip myself
|
||||
if (target.id === job.data.user.id) continue;
|
||||
|
||||
this.logger.info(`Block[${linenum}] ${target.id} ...`);
|
||||
|
||||
await this.userBlockingService.block(user, target);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Error in line:${linenum} ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.succ('Imported');
|
||||
this.logger.succ('Import jobs created');
|
||||
done();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async processDb(job: Bull.Job<DbUserImportToDbJobData>): Promise<void> {
|
||||
const line = job.data.target;
|
||||
const user = job.data.user;
|
||||
|
||||
try {
|
||||
const acct = line.split(',')[0].trim();
|
||||
const { username, host } = Acct.parse(acct);
|
||||
|
||||
if (!host) return;
|
||||
|
||||
let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
usernameLower: username.toLowerCase(),
|
||||
}) : await this.usersRepository.findOneBy({
|
||||
host: this.utilityService.toPuny(host),
|
||||
usernameLower: username.toLowerCase(),
|
||||
});
|
||||
|
||||
if (host == null && target == null) return;
|
||||
|
||||
if (target == null) {
|
||||
target = await this.remoteUserResolveService.resolveUser(username, host);
|
||||
}
|
||||
|
||||
if (target == null) {
|
||||
throw `Unable to resolve user: @${username}@${host}`;
|
||||
}
|
||||
|
||||
// skip myself
|
||||
if (target.id === job.data.user.id) return;
|
||||
|
||||
this.logger.info(`Block ${target.id} ...`);
|
||||
|
||||
this.queueService.createBlockJob([{ from: { id: user.id }, to: { id: target.id }, silent: true }]);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Error: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,34 +2,30 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository, DriveFilesRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
||||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { DbUserImportJobData } from '../types.js';
|
||||
import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ImportFollowingProcessorService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private queueService: QueueService,
|
||||
private utilityService: UtilityService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private remoteUserResolveService: RemoteUserResolveService,
|
||||
private downloadService: DownloadService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
@@ -56,46 +52,50 @@ export class ImportFollowingProcessorService {
|
||||
}
|
||||
|
||||
const csv = await this.downloadService.downloadTextFile(file.url);
|
||||
const targets = csv.trim().split('\n');
|
||||
this.queueService.createImportFollowingToDbJob({ id: user.id }, targets);
|
||||
|
||||
let linenum = 0;
|
||||
|
||||
for (const line of csv.trim().split('\n')) {
|
||||
linenum++;
|
||||
|
||||
try {
|
||||
const acct = line.split(',')[0].trim();
|
||||
const { username, host } = Acct.parse(acct);
|
||||
|
||||
let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
usernameLower: username.toLowerCase(),
|
||||
}) : await this.usersRepository.findOneBy({
|
||||
host: this.utilityService.toPuny(host!),
|
||||
usernameLower: username.toLowerCase(),
|
||||
});
|
||||
|
||||
if (host == null && target == null) continue;
|
||||
|
||||
if (target == null) {
|
||||
target = await this.remoteUserResolveService.resolveUser(username, host);
|
||||
}
|
||||
|
||||
if (target == null) {
|
||||
throw `cannot resolve user: @${username}@${host}`;
|
||||
}
|
||||
|
||||
// skip myself
|
||||
if (target.id === job.data.user.id) continue;
|
||||
|
||||
this.logger.info(`Follow[${linenum}] ${target.id} ...`);
|
||||
|
||||
this.userFollowingService.follow(user, target);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Error in line:${linenum} ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.succ('Imported');
|
||||
this.logger.succ('Import jobs created');
|
||||
done();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async processDb(job: Bull.Job<DbUserImportToDbJobData>): Promise<void> {
|
||||
const line = job.data.target;
|
||||
const user = job.data.user;
|
||||
|
||||
try {
|
||||
const acct = line.split(',')[0].trim();
|
||||
const { username, host } = Acct.parse(acct);
|
||||
|
||||
if (!host) return;
|
||||
|
||||
let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
usernameLower: username.toLowerCase(),
|
||||
}) : await this.usersRepository.findOneBy({
|
||||
host: this.utilityService.toPuny(host),
|
||||
usernameLower: username.toLowerCase(),
|
||||
});
|
||||
|
||||
if (host == null && target == null) return;
|
||||
|
||||
if (target == null) {
|
||||
target = await this.remoteUserResolveService.resolveUser(username, host);
|
||||
}
|
||||
|
||||
if (target == null) {
|
||||
throw `Unable to resolve user: @${username}@${host}`;
|
||||
}
|
||||
|
||||
// skip myself
|
||||
if (target.id === job.data.user.id) return;
|
||||
|
||||
this.logger.info(`Follow ${target.id} ...`);
|
||||
|
||||
this.queueService.createFollowJob([{ from: user, to: { id: target.id }, silent: true }]);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Error: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -66,11 +66,13 @@ export class ImportMutingProcessorService {
|
||||
const acct = line.split(',')[0].trim();
|
||||
const { username, host } = Acct.parse(acct);
|
||||
|
||||
let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({
|
||||
if (!host) continue;
|
||||
|
||||
let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
usernameLower: username.toLowerCase(),
|
||||
}) : await this.usersRepository.findOneBy({
|
||||
host: this.utilityService.toPuny(host!),
|
||||
host: this.utilityService.toPuny(host),
|
||||
usernameLower: username.toLowerCase(),
|
||||
});
|
||||
|
||||
|
@@ -0,0 +1,68 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type Bull from 'bull';
|
||||
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type Logger from '@/logger.js';
|
||||
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import { RelationshipJobData } from '../types.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
@Injectable()
|
||||
export class RelationshipProcessorService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private userBlockingService: UserBlockingService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('follow-block');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async processFollow(job: Bull.Job<RelationshipJobData>): Promise<string> {
|
||||
this.logger.info(`${job.data.from.id} is trying to follow ${job.data.to.id}`);
|
||||
await this.userFollowingService.follow(job.data.from, job.data.to, job.data.requestId, job.data.silent);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async processUnfollow(job: Bull.Job<RelationshipJobData>): Promise<string> {
|
||||
this.logger.info(`${job.data.from.id} is trying to unfollow ${job.data.to.id}`);
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: job.data.from.id }),
|
||||
this.usersRepository.findOneByOrFail({ id: job.data.to.id }),
|
||||
]);
|
||||
await this.userFollowingService.unfollow(follower, followee, job.data.silent);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async processBlock(job: Bull.Job<RelationshipJobData>): Promise<string> {
|
||||
this.logger.info(`${job.data.from.id} is trying to block ${job.data.to.id}`);
|
||||
const [blockee, blocker] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: job.data.from.id }),
|
||||
this.usersRepository.findOneByOrFail({ id: job.data.to.id }),
|
||||
]);
|
||||
await this.userBlockingService.block(blockee, blocker, job.data.silent);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async processUnblock(job: Bull.Job<RelationshipJobData>): Promise<string> {
|
||||
this.logger.info(`${job.data.from.id} is trying to unblock ${job.data.to.id}`);
|
||||
const [blockee, blocker] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: job.data.from.id }),
|
||||
this.usersRepository.findOneByOrFail({ id: job.data.to.id }),
|
||||
]);
|
||||
await this.userBlockingService.unblock(blockee, blocker);
|
||||
return 'ok';
|
||||
}
|
||||
}
|
@@ -21,9 +21,39 @@ export type InboxJobData = {
|
||||
signature: httpSignature.IParsedSignature;
|
||||
};
|
||||
|
||||
export type DbJobData = DbUserJobData | DbUserImportJobData | DbUserDeleteJobData;
|
||||
export type RelationshipJobData = {
|
||||
from: ThinUser;
|
||||
to: ThinUser;
|
||||
silent?: boolean;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export type DbUserJobData = {
|
||||
export type DbJobData<T extends keyof DbJobMap> = DbJobMap[T];
|
||||
|
||||
export type DbJobMap = {
|
||||
deleteDriveFiles: DbJobDataWithUser;
|
||||
exportCustomEmojis: DbJobDataWithUser;
|
||||
exportNotes: DbJobDataWithUser;
|
||||
exportFavorites: DbJobDataWithUser;
|
||||
exportFollowing: DbExportFollowingData;
|
||||
exportMuting: DbJobDataWithUser;
|
||||
exportBlocking: DbJobDataWithUser;
|
||||
exportUserLists: DbJobDataWithUser;
|
||||
importFollowing: DbUserImportJobData;
|
||||
importFollowingToDb: DbUserImportToDbJobData;
|
||||
importMuting: DbUserImportJobData;
|
||||
importBlocking: DbUserImportJobData;
|
||||
importBlockingToDb: DbUserImportToDbJobData;
|
||||
importUserLists: DbUserImportJobData;
|
||||
importCustomEmojis: DbUserImportJobData;
|
||||
deleteAccount: DbUserDeleteJobData;
|
||||
}
|
||||
|
||||
export type DbJobDataWithUser = {
|
||||
user: ThinUser;
|
||||
}
|
||||
|
||||
export type DbExportFollowingData = {
|
||||
user: ThinUser;
|
||||
excludeMuting: boolean;
|
||||
excludeInactive: boolean;
|
||||
@@ -39,6 +69,11 @@ export type DbUserImportJobData = {
|
||||
fileId: DriveFile['id'];
|
||||
};
|
||||
|
||||
export type DbUserImportToDbJobData = {
|
||||
user: ThinUser;
|
||||
target: string;
|
||||
};
|
||||
|
||||
export type ObjectStorageJobData = ObjectStorageFileJobData | Record<string, unknown>;
|
||||
|
||||
export type ObjectStorageFileJobData = {
|
||||
|
@@ -89,7 +89,7 @@ export class ApiServerService {
|
||||
Params: { endpoint: string; },
|
||||
Body: Record<string, unknown>,
|
||||
Querystring: Record<string, unknown>,
|
||||
}>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, async (request, reply) => {
|
||||
}>('/' + endpoint.name, { bodyLimit: 1024 * 1024 }, async (request, reply) => {
|
||||
if (request.method === 'GET' && !endpoint.meta.allowGet) {
|
||||
reply.code(405);
|
||||
reply.send();
|
||||
|
@@ -98,6 +98,7 @@ import * as ep___channels_update from './endpoints/channels/update.js';
|
||||
import * as ep___channels_favorite from './endpoints/channels/favorite.js';
|
||||
import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js';
|
||||
import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js';
|
||||
import * as ep___channels_search from './endpoints/channels/search.js';
|
||||
import * as ep___charts_activeUsers from './endpoints/charts/active-users.js';
|
||||
import * as ep___charts_apRequest from './endpoints/charts/ap-request.js';
|
||||
import * as ep___charts_drive from './endpoints/charts/drive.js';
|
||||
@@ -431,6 +432,7 @@ const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep
|
||||
const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default };
|
||||
const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default };
|
||||
const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default };
|
||||
const $channels_search: Provider = { provide: 'ep:channels/search', useClass: ep___channels_search.default };
|
||||
const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default };
|
||||
const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default };
|
||||
const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default };
|
||||
@@ -768,6 +770,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$channels_favorite,
|
||||
$channels_unfavorite,
|
||||
$channels_myFavorites,
|
||||
$channels_search,
|
||||
$charts_activeUsers,
|
||||
$charts_apRequest,
|
||||
$charts_drive,
|
||||
@@ -1099,6 +1102,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$channels_favorite,
|
||||
$channels_unfavorite,
|
||||
$channels_myFavorites,
|
||||
$channels_search,
|
||||
$charts_activeUsers,
|
||||
$charts_apRequest,
|
||||
$charts_drive,
|
||||
|
@@ -22,8 +22,8 @@ export class StreamingApiServerService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redisForPubsub)
|
||||
private redisForPubsub: Redis.Redis,
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
@@ -81,7 +81,7 @@ export class StreamingApiServerService {
|
||||
ev.emit(parsed.channel, parsed.message);
|
||||
}
|
||||
|
||||
this.redisForPubsub.on('message', onRedisMessage);
|
||||
this.redisForSub.on('message', onRedisMessage);
|
||||
|
||||
const main = new MainStreamConnection(
|
||||
this.channelsService,
|
||||
@@ -111,7 +111,7 @@ export class StreamingApiServerService {
|
||||
connection.once('close', () => {
|
||||
ev.removeAllListeners();
|
||||
main.dispose();
|
||||
this.redisForPubsub.off('message', onRedisMessage);
|
||||
this.redisForSub.off('message', onRedisMessage);
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
});
|
||||
|
||||
|
@@ -98,6 +98,7 @@ import * as ep___channels_update from './endpoints/channels/update.js';
|
||||
import * as ep___channels_favorite from './endpoints/channels/favorite.js';
|
||||
import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js';
|
||||
import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js';
|
||||
import * as ep___channels_search from './endpoints/channels/search.js';
|
||||
import * as ep___charts_activeUsers from './endpoints/charts/active-users.js';
|
||||
import * as ep___charts_apRequest from './endpoints/charts/ap-request.js';
|
||||
import * as ep___charts_drive from './endpoints/charts/drive.js';
|
||||
@@ -429,6 +430,7 @@ const eps = [
|
||||
['channels/favorite', ep___channels_favorite],
|
||||
['channels/unfavorite', ep___channels_unfavorite],
|
||||
['channels/my-favorites', ep___channels_myFavorites],
|
||||
['channels/search', ep___channels_search],
|
||||
['charts/active-users', ep___charts_activeUsers],
|
||||
['charts/ap-request', ep___charts_apRequest],
|
||||
['charts/drive', ep___charts_drive],
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { FollowingsRepository, UsersRepository } from '@/models/index.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
@@ -29,7 +29,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
@Inject(DI.notesRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userFollowingService: UserFollowingService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const followings = await this.followingsRepository.findBy({
|
||||
@@ -41,9 +41,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
this.usersRepository.findOneByOrFail({ id: f.followeeId }),
|
||||
])));
|
||||
|
||||
for (const pair of pairs) {
|
||||
this.userFollowingService.unfollow(pair[0], pair[1]);
|
||||
}
|
||||
this.queueService.createUnfollowJob(pairs.map(p => ({ to: p[0], from: p[1], silent: true })));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,15 +1,15 @@
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { UsersRepository, FollowingsRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { RelationshipJobData } from '@/queue/types.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { UserSuspendService } from '@/core/UserSuspendService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
@@ -36,12 +36,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private userSuspendService: UserSuspendService,
|
||||
private roleService: RoleService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const user = await this.usersRepository.findOneBy({ id: ps.userId });
|
||||
@@ -71,20 +69,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
@bindThis
|
||||
private async unFollowAll(follower: User) {
|
||||
const followings = await this.followingsRepository.findBy({
|
||||
followerId: follower.id,
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const jobs: RelationshipJobData[] = [];
|
||||
for (const following of followings) {
|
||||
const followee = await this.usersRepository.findOneBy({
|
||||
id: following.followeeId,
|
||||
});
|
||||
|
||||
if (followee == null) {
|
||||
throw `Cant find followee ${following.followeeId}`;
|
||||
if (following.followeeId && following.followerId) {
|
||||
jobs.push({
|
||||
from: { id: following.followerId },
|
||||
to: { id: following.followeeId },
|
||||
silent: true,
|
||||
});
|
||||
}
|
||||
|
||||
await this.userFollowingService.unfollow(follower, followee, true);
|
||||
}
|
||||
this.queueService.createUnfollowJob(jobs);
|
||||
}
|
||||
}
|
||||
|
67
packages/backend/src/server/api/endpoints/channels/search.ts
Normal file
67
packages/backend/src/server/api/endpoints/channels/search.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import type { ChannelsRepository } from '@/models/index.js';
|
||||
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels'],
|
||||
|
||||
requireCredential: false,
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Channel',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
type: { type: 'string', enum: ['nameAndDescription', 'nameOnly'], default: 'nameAndDescription' },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 5 },
|
||||
},
|
||||
required: ['query'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
|
||||
private channelEntityService: ChannelEntityService,
|
||||
private queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId);
|
||||
|
||||
if (ps.type === 'nameAndDescription') {
|
||||
query.andWhere(new Brackets(qb => { qb
|
||||
.where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
|
||||
.orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
|
||||
}));
|
||||
} else {
|
||||
query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
|
||||
}
|
||||
|
||||
const channels = await query
|
||||
.take(ps.limit)
|
||||
.getMany();
|
||||
|
||||
return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me)));
|
||||
});
|
||||
}
|
||||
}
|
@@ -54,10 +54,10 @@ class LocalTimelineChannel extends Channel {
|
||||
}
|
||||
|
||||
// 関係ない返信は除外
|
||||
if (note.reply && !this.user!.showTimelineReplies) {
|
||||
if (note.reply && this.user && !this.user.showTimelineReplies) {
|
||||
const reply = note.reply;
|
||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
|
||||
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
|
||||
}
|
||||
|
||||
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
|
||||
|
@@ -77,6 +77,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
|
||||
createdAt: '2016-12-28T22:49:51.000Z',
|
||||
description: 'I am a cool user!',
|
||||
ffVisibility: 'public',
|
||||
roles: [],
|
||||
fields: [
|
||||
{
|
||||
name: 'Website',
|
||||
|
@@ -398,6 +398,7 @@ function toStories(component: string): string {
|
||||
Promise.all([
|
||||
glob('src/components/global/*.vue'),
|
||||
glob('src/components/MkGalleryPostPreview.vue'),
|
||||
glob('src/pages/user/home.vue'),
|
||||
])
|
||||
.then((globs) => globs.flat())
|
||||
.then((components) => Promise.all(components.map((component) => {
|
||||
|
31
packages/frontend/src/components/MkChannelList.vue
Normal file
31
packages/frontend/src/components/MkChannelList.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<MkPagination :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
|
||||
<div>{{ i18n.ts.notFound }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<MkChannelPreview v-for="item in items" :key="item.id" class="_margin" :channel="extractor(item)"/>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MkChannelPreview from '@/components/MkChannelPreview.vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
pagination: Paging;
|
||||
noGap?: boolean;
|
||||
extractor?: (item: any) => any;
|
||||
}>(), {
|
||||
extractor: (item) => item,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
@@ -82,6 +82,7 @@ export default defineComponent({
|
||||
omitted: null,
|
||||
ignoreOmit: false,
|
||||
defaultStore,
|
||||
i18n,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
|
@@ -439,7 +439,6 @@ defineExpose({
|
||||
|
||||
&.asDrawer {
|
||||
width: 100% !important;
|
||||
padding: 12px 0 max(env(safe-area-inset-bottom, 0px), 12px) 0;
|
||||
|
||||
> .emojis {
|
||||
::v-deep(section) {
|
||||
@@ -498,6 +497,10 @@ defineExpose({
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
|
||||
&:not(:focus):not(.filled) {
|
||||
margin-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
&:not(.filled) {
|
||||
order: 1;
|
||||
z-index: 2;
|
||||
|
@@ -31,7 +31,7 @@
|
||||
import { onMounted } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import VuePlyr from 'vue-plyr';
|
||||
import { ColdDeviceStorage } from '@/store';
|
||||
import { soundConfigStore } from '@/scripts/sound';
|
||||
import 'vue-plyr/dist/vue-plyr.css';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
@@ -44,11 +44,11 @@ const audioEl = $shallowRef<HTMLAudioElement | null>();
|
||||
let hide = $ref(true);
|
||||
|
||||
function volumechange() {
|
||||
if (audioEl) ColdDeviceStorage.set('mediaVolume', audioEl.volume);
|
||||
if (audioEl) soundConfigStore.set('mediaVolume', audioEl.volume);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (audioEl) audioEl.volume = ColdDeviceStorage.get('mediaVolume');
|
||||
if (audioEl) audioEl.volume = soundConfigStore.state.mediaVolume;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@@ -1124,16 +1124,16 @@ defineExpose({
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
|
||||
grid-auto-rows: 46px;
|
||||
grid-auto-rows: 40px;
|
||||
}
|
||||
|
||||
.footerRight {
|
||||
flex: 0.3;
|
||||
flex: 0;
|
||||
margin-left: auto;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
|
||||
grid-auto-rows: 46px;
|
||||
grid-auto-rows: 40px;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
@@ -1198,13 +1198,21 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 330px) {
|
||||
@container (max-width: 350px) {
|
||||
.footer {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.footerLeft {
|
||||
grid-template-columns: repeat(auto-fill, minmax(38px, 1fr));
|
||||
}
|
||||
|
||||
.footerRight {
|
||||
grid-template-columns: repeat(auto-fill, minmax(38px, 1fr));
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -83,7 +83,7 @@ const choseAd = (): Ad | null => {
|
||||
};
|
||||
|
||||
const chosen = ref(choseAd());
|
||||
const shouldHide = $ref($i && $i.policies.canHideAds && (props.specify == null));
|
||||
const shouldHide = $ref(!defaultStore.state.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null));
|
||||
|
||||
function reduceFrequency(): void {
|
||||
if (chosen.value == null) return;
|
||||
|
@@ -5,7 +5,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { getStaticImageUrl } from '@/scripts/media-proxy';
|
||||
import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy';
|
||||
import { defaultStore } from '@/store';
|
||||
import { customEmojis } from '@/custom-emojis';
|
||||
|
||||
@@ -15,25 +15,38 @@ const props = defineProps<{
|
||||
noStyle?: boolean;
|
||||
host?: string | null;
|
||||
url?: string;
|
||||
useOriginalSize?: boolean;
|
||||
}>();
|
||||
|
||||
const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substr(1, props.name.length - 2) : props.name).replace('@.', ''));
|
||||
const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@')));
|
||||
|
||||
const rawUrl = computed(() => {
|
||||
if (props.url) {
|
||||
return props.url;
|
||||
}
|
||||
if (props.host == null && !customEmojiName.value.includes('@')) {
|
||||
if (isLocal.value) {
|
||||
return customEmojis.value.find(x => x.name === customEmojiName.value)?.url ?? null;
|
||||
}
|
||||
return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`;
|
||||
});
|
||||
|
||||
const url = computed(() =>
|
||||
defaultStore.reactiveState.disableShowingAnimatedImages.value && rawUrl.value
|
||||
? getStaticImageUrl(rawUrl.value)
|
||||
: rawUrl.value,
|
||||
);
|
||||
const url = computed(() => {
|
||||
if (rawUrl.value == null) return null;
|
||||
|
||||
const proxied =
|
||||
(rawUrl.value.startsWith('/emoji/') || (props.useOriginalSize && isLocal.value))
|
||||
? rawUrl.value
|
||||
: getProxiedImageUrl(
|
||||
rawUrl.value,
|
||||
props.useOriginalSize ? undefined : 'emoji',
|
||||
false,
|
||||
true,
|
||||
);
|
||||
return defaultStore.reactiveState.disableShowingAnimatedImages.value
|
||||
? getStaticImageUrl(proxied)
|
||||
: proxied;
|
||||
});
|
||||
|
||||
const alt = computed(() => `:${customEmojiName.value}:`);
|
||||
let errored = $ref(url.value == null);
|
||||
|
@@ -51,6 +51,10 @@ export default defineComponent({
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
rootScale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
@@ -65,7 +69,12 @@ export default defineComponent({
|
||||
|
||||
const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm;
|
||||
|
||||
const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode | string | (VNode | string)[] => {
|
||||
/**
|
||||
* Gen Vue Elements from MFM AST
|
||||
* @param ast MFM AST
|
||||
* @param scale How times large the text is
|
||||
*/
|
||||
const genEl = (ast: mfm.MfmNode[], scale: number) => ast.map((token): VNode | string | (VNode | string)[] => {
|
||||
switch (token.type) {
|
||||
case 'text': {
|
||||
const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
|
||||
@@ -84,17 +93,17 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
case 'bold': {
|
||||
return [h('b', genEl(token.children))];
|
||||
return [h('b', genEl(token.children, scale))];
|
||||
}
|
||||
|
||||
case 'strike': {
|
||||
return [h('del', genEl(token.children))];
|
||||
return [h('del', genEl(token.children, scale))];
|
||||
}
|
||||
|
||||
case 'italic': {
|
||||
return h('i', {
|
||||
style: 'font-style: oblique;',
|
||||
}, genEl(token.children));
|
||||
}, genEl(token.children, scale));
|
||||
}
|
||||
|
||||
case 'fn': {
|
||||
@@ -155,17 +164,17 @@ export default defineComponent({
|
||||
case 'x2': {
|
||||
return h('span', {
|
||||
class: defaultStore.state.advancedMfm ? 'mfm-x2' : '',
|
||||
}, genEl(token.children));
|
||||
}, genEl(token.children, scale * 2));
|
||||
}
|
||||
case 'x3': {
|
||||
return h('span', {
|
||||
class: defaultStore.state.advancedMfm ? 'mfm-x3' : '',
|
||||
}, genEl(token.children));
|
||||
}, genEl(token.children, scale * 3));
|
||||
}
|
||||
case 'x4': {
|
||||
return h('span', {
|
||||
class: defaultStore.state.advancedMfm ? 'mfm-x4' : '',
|
||||
}, genEl(token.children));
|
||||
}, genEl(token.children, scale * 4));
|
||||
}
|
||||
case 'font': {
|
||||
const family =
|
||||
@@ -182,7 +191,7 @@ export default defineComponent({
|
||||
case 'blur': {
|
||||
return h('span', {
|
||||
class: '_mfm_blur_',
|
||||
}, genEl(token.children));
|
||||
}, genEl(token.children, scale));
|
||||
}
|
||||
case 'rainbow': {
|
||||
const speed = validTime(token.props.args.speed) ?? '1s';
|
||||
@@ -191,9 +200,9 @@ export default defineComponent({
|
||||
}
|
||||
case 'sparkle': {
|
||||
if (!useAnim) {
|
||||
return genEl(token.children);
|
||||
return genEl(token.children, scale);
|
||||
}
|
||||
return h(MkSparkle, {}, genEl(token.children));
|
||||
return h(MkSparkle, {}, genEl(token.children, scale));
|
||||
}
|
||||
case 'rotate': {
|
||||
const degrees = parseFloat(token.props.args.deg ?? '90');
|
||||
@@ -214,7 +223,8 @@ export default defineComponent({
|
||||
}
|
||||
const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5);
|
||||
const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5);
|
||||
style = `transform: scale(${x}, ${y});`;
|
||||
style = `transform: scale(${x}, ${y});`;
|
||||
scale = scale * Math.max(x, y);
|
||||
break;
|
||||
}
|
||||
case 'fg': {
|
||||
@@ -231,24 +241,24 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
if (style == null) {
|
||||
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']);
|
||||
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
|
||||
} else {
|
||||
return h('span', {
|
||||
style: 'display: inline-block; ' + style,
|
||||
}, genEl(token.children));
|
||||
}, genEl(token.children, scale));
|
||||
}
|
||||
}
|
||||
|
||||
case 'small': {
|
||||
return [h('small', {
|
||||
style: 'opacity: 0.7;',
|
||||
}, genEl(token.children))];
|
||||
}, genEl(token.children, scale))];
|
||||
}
|
||||
|
||||
case 'center': {
|
||||
return [h('div', {
|
||||
style: 'text-align:center;',
|
||||
}, genEl(token.children))];
|
||||
}, genEl(token.children, scale))];
|
||||
}
|
||||
|
||||
case 'url': {
|
||||
@@ -264,7 +274,7 @@ export default defineComponent({
|
||||
key: Math.random(),
|
||||
url: token.props.url,
|
||||
rel: 'nofollow noopener',
|
||||
}, genEl(token.children))];
|
||||
}, genEl(token.children, scale))];
|
||||
}
|
||||
|
||||
case 'mention': {
|
||||
@@ -303,11 +313,11 @@ export default defineComponent({
|
||||
if (!this.nowrap) {
|
||||
return [h('div', {
|
||||
style: QUOTE_STYLE,
|
||||
}, genEl(token.children))];
|
||||
}, genEl(token.children, scale))];
|
||||
} else {
|
||||
return [h('span', {
|
||||
style: QUOTE_STYLE,
|
||||
}, genEl(token.children))];
|
||||
}, genEl(token.children, scale))];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +329,7 @@ export default defineComponent({
|
||||
name: token.props.name,
|
||||
normal: this.plain,
|
||||
host: null,
|
||||
useOriginalSize: scale >= 2.5,
|
||||
})];
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
@@ -332,6 +343,7 @@ export default defineComponent({
|
||||
url: this.emojiUrls ? this.emojiUrls[token.props.name] : null,
|
||||
normal: this.plain,
|
||||
host: this.author.host,
|
||||
useOriginalSize: scale >= 2.5,
|
||||
})];
|
||||
}
|
||||
}
|
||||
@@ -360,7 +372,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
case 'plain': {
|
||||
return [h('span', genEl(token.children))];
|
||||
return [h('span', genEl(token.children, scale))];
|
||||
}
|
||||
|
||||
default: {
|
||||
@@ -373,6 +385,6 @@ export default defineComponent({
|
||||
}).flat(Infinity) as (VNode | string)[];
|
||||
|
||||
// Parse ast to DOM
|
||||
return h('span', genEl(ast));
|
||||
return h('span', genEl(ast, this.rootScale));
|
||||
},
|
||||
});
|
||||
|
@@ -2,6 +2,23 @@
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="700">
|
||||
<div v-if="tab === 'search'">
|
||||
<div class="_gaps">
|
||||
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search">
|
||||
<template #prefix><i class="ti ti-search"></i></template>
|
||||
</MkInput>
|
||||
<MkRadios v-model="searchType" @update:model-value="search()">
|
||||
<option value="nameAndDescription">{{ i18n.ts._channel.nameAndDescription }}</option>
|
||||
<option value="nameOnly">{{ i18n.ts._channel.nameOnly }}</option>
|
||||
</MkRadios>
|
||||
<MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton>
|
||||
</div>
|
||||
|
||||
<MkFoldableSection v-if="channelPagination">
|
||||
<template #header>{{ i18n.ts.searchResult }}</template>
|
||||
<MkChannelList :key="key" :pagination="channelPagination"/>
|
||||
</MkFoldableSection>
|
||||
</div>
|
||||
<div v-if="tab === 'featured'">
|
||||
<MkPagination v-slot="{items}" :pagination="featuredPagination">
|
||||
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
|
||||
@@ -28,17 +45,35 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import MkChannelPreview from '@/components/MkChannelPreview.vue';
|
||||
import MkChannelList from '@/components/MkChannelList.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
import { useRouter } from '@/router';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let tab = $ref('featured');
|
||||
const props = defineProps<{
|
||||
query: string;
|
||||
type?: string;
|
||||
}>();
|
||||
|
||||
let key = $ref('');
|
||||
let tab = $ref('search');
|
||||
let searchQuery = $ref('');
|
||||
let searchType = $ref('nameAndDescription');
|
||||
let channelPagination = $ref();
|
||||
|
||||
onMounted(() => {
|
||||
searchQuery = props.query ?? '';
|
||||
searchType = props.type ?? 'nameAndDescription';
|
||||
});
|
||||
|
||||
const featuredPagination = {
|
||||
endpoint: 'channels/featured' as const,
|
||||
@@ -58,6 +93,25 @@ const ownedPagination = {
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
async function search() {
|
||||
const query = searchQuery.toString().trim();
|
||||
|
||||
if (query == null || query === '') return;
|
||||
|
||||
const type = searchType.toString().trim();
|
||||
|
||||
channelPagination = {
|
||||
endpoint: 'channels/search',
|
||||
limit: 10,
|
||||
params: {
|
||||
query: searchQuery,
|
||||
type: type,
|
||||
},
|
||||
};
|
||||
|
||||
key = query + type;
|
||||
}
|
||||
|
||||
function create() {
|
||||
router.push('/channels/new');
|
||||
}
|
||||
@@ -69,6 +123,10 @@ const headerActions = $computed(() => [{
|
||||
}]);
|
||||
|
||||
const headerTabs = $computed(() => [{
|
||||
key: 'search',
|
||||
title: i18n.ts.search,
|
||||
icon: 'ti ti-search',
|
||||
}, {
|
||||
key: 'featured',
|
||||
title: i18n.ts._channel.featured,
|
||||
icon: 'ti ti-comet',
|
||||
|
@@ -66,7 +66,7 @@ const recentPostsPagination = {
|
||||
};
|
||||
const popularPostsPagination = {
|
||||
endpoint: 'gallery/featured' as const,
|
||||
limit: 5,
|
||||
noPaging: true,
|
||||
};
|
||||
const myPostsPagination = {
|
||||
endpoint: 'i/gallery/posts' as const,
|
||||
|
@@ -8,27 +8,29 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{items}">
|
||||
<div v-for="token in items" :key="token.id" class="_panel bfomjevm">
|
||||
<img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
|
||||
<div class="body">
|
||||
<div class="name">{{ token.name }}</div>
|
||||
<div class="description">{{ token.description }}</div>
|
||||
<MkKeyValue oneline>
|
||||
<template #key>{{ i18n.ts.installedDate }}</template>
|
||||
<template #value><MkTime :time="token.createdAt"/></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue oneline>
|
||||
<template #key>{{ i18n.ts.lastUsedDate }}</template>
|
||||
<template #value><MkTime :time="token.lastUsedAt"/></template>
|
||||
</MkKeyValue>
|
||||
<details>
|
||||
<summary>{{ i18n.ts.details }}</summary>
|
||||
<ul>
|
||||
<li v-for="p in token.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
|
||||
</ul>
|
||||
</details>
|
||||
<div class="actions">
|
||||
<MkButton inline danger @click="revoke(token)"><i class="ti ti-trash"></i></MkButton>
|
||||
<div class="_gaps">
|
||||
<div v-for="token in items" :key="token.id" class="_panel bfomjevm">
|
||||
<img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
|
||||
<div class="body">
|
||||
<div class="name">{{ token.name }}</div>
|
||||
<div class="description">{{ token.description }}</div>
|
||||
<MkKeyValue oneline>
|
||||
<template #key>{{ i18n.ts.installedDate }}</template>
|
||||
<template #value><MkTime :time="token.createdAt"/></template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue oneline>
|
||||
<template #key>{{ i18n.ts.lastUsedDate }}</template>
|
||||
<template #value><MkTime :time="token.lastUsedAt"/></template>
|
||||
</MkKeyValue>
|
||||
<details>
|
||||
<summary>{{ i18n.ts.details }}</summary>
|
||||
<ul>
|
||||
<li v-for="p in token.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
|
||||
</ul>
|
||||
</details>
|
||||
<div class="actions">
|
||||
<MkButton inline danger @click="revoke(token)"><i class="ti ti-trash"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,6 +53,7 @@ const list = ref<any>(null);
|
||||
const pagination = {
|
||||
endpoint: 'i/apps' as const,
|
||||
limit: 100,
|
||||
noPaging: true,
|
||||
params: {
|
||||
sort: '+lastUsedAt',
|
||||
},
|
||||
|
@@ -61,6 +61,7 @@
|
||||
<MkSwitch v-model="squareAvatars">{{ i18n.ts.squareAvatars }}</MkSwitch>
|
||||
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
|
||||
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
|
||||
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
|
||||
</div>
|
||||
<div>
|
||||
<MkRadios v-model="emojiStyle">
|
||||
@@ -163,6 +164,7 @@ const advancedMfm = computed(defaultStore.makeGetterSetter('advancedMfm'));
|
||||
const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle'));
|
||||
const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer'));
|
||||
const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages'));
|
||||
const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds'));
|
||||
const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
|
||||
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
|
||||
const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
|
||||
|
@@ -400,7 +400,7 @@ function menu(ev: MouseEvent, profileId: string) {
|
||||
icon: 'ti ti-device-floppy',
|
||||
action: () => save(profileId),
|
||||
}, null, {
|
||||
text: ts._preferencesBackups.delete,
|
||||
text: ts.delete,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => deleteProfile(profileId),
|
||||
danger: true,
|
||||
|
@@ -7,7 +7,7 @@
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts.sounds }}</template>
|
||||
<div class="_gaps_s">
|
||||
<MkFolder v-for="type in Object.keys(sounds)" :key="type">
|
||||
<MkFolder v-for="type in soundsKeys" :key="type">
|
||||
<template #label>{{ i18n.t('_sfx.' + type) }}</template>
|
||||
<template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template>
|
||||
|
||||
@@ -21,51 +21,44 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { Ref, computed, ref } from 'vue';
|
||||
import XSound from './sounds.sound.vue';
|
||||
import MkRange from '@/components/MkRange.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import { ColdDeviceStorage } from '@/store';
|
||||
import { soundConfigStore } from '@/scripts/sound';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
|
||||
const masterVolume = computed({
|
||||
get: () => {
|
||||
return ColdDeviceStorage.get('sound_masterVolume');
|
||||
},
|
||||
set: (value) => {
|
||||
ColdDeviceStorage.set('sound_masterVolume', value);
|
||||
},
|
||||
const masterVolume = computed(soundConfigStore.makeGetterSetter('sound_masterVolume'));
|
||||
|
||||
const soundsKeys = ['note', 'noteMy', 'notification', 'chat', 'chatBg', 'antenna', 'channel'] as const;
|
||||
|
||||
const sounds = ref<Record<typeof soundsKeys[number], Ref<any>>>({
|
||||
note: soundConfigStore.reactiveState.sound_note,
|
||||
noteMy: soundConfigStore.reactiveState.sound_noteMy,
|
||||
notification: soundConfigStore.reactiveState.sound_notification,
|
||||
chat: soundConfigStore.reactiveState.sound_chat,
|
||||
chatBg: soundConfigStore.reactiveState.sound_chatBg,
|
||||
antenna: soundConfigStore.reactiveState.sound_antenna,
|
||||
channel: soundConfigStore.reactiveState.sound_channel,
|
||||
});
|
||||
|
||||
const volumeIcon = computed(() => masterVolume.value === 0 ? 'ti ti-volume-3' : 'ti ti-volume');
|
||||
|
||||
const sounds = ref({
|
||||
note: ColdDeviceStorage.get('sound_note'),
|
||||
noteMy: ColdDeviceStorage.get('sound_noteMy'),
|
||||
notification: ColdDeviceStorage.get('sound_notification'),
|
||||
chat: ColdDeviceStorage.get('sound_chat'),
|
||||
chatBg: ColdDeviceStorage.get('sound_chatBg'),
|
||||
antenna: ColdDeviceStorage.get('sound_antenna'),
|
||||
channel: ColdDeviceStorage.get('sound_channel'),
|
||||
});
|
||||
|
||||
async function updated(type, sound) {
|
||||
async function updated(type: keyof typeof sounds.value, sound) {
|
||||
const v = {
|
||||
type: sound.type,
|
||||
volume: sound.volume,
|
||||
};
|
||||
|
||||
ColdDeviceStorage.set('sound_' + type, v);
|
||||
soundConfigStore.set(`sound_${type}`, v);
|
||||
sounds.value[type] = v;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
for (const sound of Object.keys(sounds.value)) {
|
||||
const v = ColdDeviceStorage.default['sound_' + sound];
|
||||
ColdDeviceStorage.set('sound_' + sound, v);
|
||||
for (const sound of Object.keys(sounds.value) as Array<keyof typeof sounds.value>) {
|
||||
const v = soundConfigStore.def[`sound_${sound}`].default;
|
||||
soundConfigStore.set(`sound_${sound}`, v);
|
||||
sounds.value[sound] = v;
|
||||
}
|
||||
}
|
||||
|
@@ -7,18 +7,20 @@
|
||||
<FormSection>
|
||||
<MkPagination :pagination="pagination">
|
||||
<template #default="{items}">
|
||||
<FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`" class="_margin">
|
||||
<template #icon>
|
||||
<i v-if="webhook.active === false" class="ti ti-player-pause"></i>
|
||||
<i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i>
|
||||
<i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--success)' }"></i>
|
||||
<i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"></i>
|
||||
</template>
|
||||
{{ webhook.name || webhook.url }}
|
||||
<template #suffix>
|
||||
<MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime>
|
||||
</template>
|
||||
</FormLink>
|
||||
<div class="_gaps">
|
||||
<FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`">
|
||||
<template #icon>
|
||||
<i v-if="webhook.active === false" class="ti ti-player-pause"></i>
|
||||
<i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i>
|
||||
<i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--success)' }"></i>
|
||||
<i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"></i>
|
||||
</template>
|
||||
{{ webhook.name || webhook.url }}
|
||||
<template #suffix>
|
||||
<MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime>
|
||||
</template>
|
||||
</FormLink>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</FormSection>
|
||||
@@ -35,7 +37,8 @@ import { i18n } from '@/i18n';
|
||||
|
||||
const pagination = {
|
||||
endpoint: 'i/webhooks/list' as const,
|
||||
limit: 10,
|
||||
limit: 100,
|
||||
noPaging: true,
|
||||
};
|
||||
|
||||
const headerActions = $computed(() => []);
|
||||
|
74
packages/frontend/src/pages/user/home.stories.impl.ts
Normal file
74
packages/frontend/src/pages/user/home.stories.impl.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { userDetailed } from '../../../.storybook/fakes';
|
||||
import { commonHandlers } from '../../../.storybook/mocks';
|
||||
import home_ from './home.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
home_,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<home_ v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
user: userDetailed(),
|
||||
disableNotes: false,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users/notes', (req, res, ctx) => {
|
||||
return res(ctx.json([]));
|
||||
}),
|
||||
rest.get('/api/charts/user/notes', (req, res, ctx) => {
|
||||
const length = Math.max(Math.min(parseInt(req.url.searchParams.get('limit') ?? '30', 10), 1), 300);
|
||||
return res(ctx.json({
|
||||
total: Array.from({ length }, () => 0),
|
||||
inc: Array.from({ length }, () => 0),
|
||||
dec: Array.from({ length }, () => 0),
|
||||
diffs: {
|
||||
normal: Array.from({ length }, () => 0),
|
||||
reply: Array.from({ length }, () => 0),
|
||||
renote: Array.from({ length }, () => 0),
|
||||
withFile: Array.from({ length }, () => 0),
|
||||
},
|
||||
}));
|
||||
}),
|
||||
rest.get('/api/charts/user/pv', (req, res, ctx) => {
|
||||
const length = Math.max(Math.min(parseInt(req.url.searchParams.get('limit') ?? '30', 10), 1), 300);
|
||||
return res(ctx.json({
|
||||
upv: {
|
||||
user: Array.from({ length }, () => 0),
|
||||
visitor: Array.from({ length }, () => 0),
|
||||
},
|
||||
pv: {
|
||||
user: Array.from({ length }, () => 0),
|
||||
visitor: Array.from({ length }, () => 0),
|
||||
},
|
||||
}));
|
||||
}),
|
||||
],
|
||||
},
|
||||
chromatic: {
|
||||
// `XActivity` is not compatible with Chromatic for now
|
||||
disableSnapshot: true,
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof home_>;
|
@@ -2,7 +2,7 @@ import { query } from '@/scripts/url';
|
||||
import { url } from '@/config';
|
||||
import { instance } from '@/instance';
|
||||
|
||||
export function getProxiedImageUrl(imageUrl: string, type?: 'preview', mustOrigin: boolean = false): string {
|
||||
export function getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin: boolean = false, noFallback: boolean = false): string {
|
||||
const localProxy = `${url}/proxy`;
|
||||
|
||||
if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) {
|
||||
@@ -15,7 +15,7 @@ export function getProxiedImageUrl(imageUrl: string, type?: 'preview', mustOrigi
|
||||
: 'image.webp'
|
||||
}?${query({
|
||||
url: imageUrl,
|
||||
fallback: '1',
|
||||
...(!noFallback ? { 'fallback': '1' } : {}),
|
||||
...(type ? { [type]: '1' } : {}),
|
||||
...(mustOrigin ? { origin: '1' } : {}),
|
||||
})}`;
|
||||
|
@@ -1,4 +1,56 @@
|
||||
import { ColdDeviceStorage } from '@/store';
|
||||
import { markRaw } from 'vue';
|
||||
import { Storage } from '@/pizzax';
|
||||
|
||||
export const soundConfigStore = markRaw(new Storage('sound', {
|
||||
mediaVolume: {
|
||||
where: 'device',
|
||||
default: 0.5
|
||||
},
|
||||
sound_masterVolume: {
|
||||
where: 'device',
|
||||
default: 0.3
|
||||
},
|
||||
sound_note: {
|
||||
where: 'account',
|
||||
default: { type: 'syuilo/n-aec', volume: 1 }
|
||||
},
|
||||
sound_noteMy: {
|
||||
where: 'account',
|
||||
default: { type: 'syuilo/n-cea-4va', volume: 1 }
|
||||
},
|
||||
sound_notification: {
|
||||
where: 'account',
|
||||
default: { type: 'syuilo/n-ea', volume: 1 }
|
||||
},
|
||||
sound_chat: {
|
||||
where: 'account',
|
||||
default: { type: 'syuilo/pope1', volume: 1 }
|
||||
},
|
||||
sound_chatBg: {
|
||||
where: 'account',
|
||||
default: { type: 'syuilo/waon', volume: 1 }
|
||||
},
|
||||
sound_antenna: {
|
||||
where: 'account',
|
||||
default: { type: 'syuilo/triple', volume: 1 }
|
||||
},
|
||||
sound_channel: {
|
||||
where: 'account',
|
||||
default: { type: 'syuilo/square-pico', volume: 1 }
|
||||
},
|
||||
}));
|
||||
|
||||
await soundConfigStore.ready;
|
||||
|
||||
//#region サウンドのColdDeviceStorage => indexedDBのマイグレーション
|
||||
for (const target of Object.keys(soundConfigStore.state) as Array<keyof typeof soundConfigStore.state>) {
|
||||
const value = localStorage.getItem(`miux:${target}`);
|
||||
if (value) {
|
||||
soundConfigStore.set(target, JSON.parse(value) as typeof soundConfigStore.def[typeof target]['default']);
|
||||
localStorage.removeItem(`miux:${target}`);
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const cache = new Map<string, HTMLAudioElement>();
|
||||
|
||||
@@ -67,19 +119,20 @@ export function getAudio(file: string, useCache = true): HTMLAudioElement {
|
||||
}
|
||||
|
||||
export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement {
|
||||
const masterVolume = ColdDeviceStorage.get('sound_masterVolume');
|
||||
const masterVolume = soundConfigStore.state.sound_masterVolume;
|
||||
audio.volume = masterVolume - ((1 - volume) * masterVolume);
|
||||
return audio;
|
||||
}
|
||||
|
||||
export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification') {
|
||||
const sound = ColdDeviceStorage.get(`sound_${type}`);
|
||||
const sound = soundConfigStore.state[`sound_${type}`];
|
||||
if (_DEV_) console.log('play', type, sound);
|
||||
if (sound.type == null) return;
|
||||
playFile(sound.type, sound.volume);
|
||||
}
|
||||
|
||||
export function playFile(file: string, volume: number) {
|
||||
const masterVolume = ColdDeviceStorage.get('sound_masterVolume');
|
||||
const masterVolume = soundConfigStore.state.sound_masterVolume;
|
||||
if (masterVolume === 0) return;
|
||||
|
||||
const audio = setVolume(getAudio(file), volume);
|
||||
|
@@ -298,6 +298,10 @@ export const defaultStore = markRaw(new Storage('base', {
|
||||
where: 'device',
|
||||
default: false,
|
||||
},
|
||||
forceShowAds: {
|
||||
where: 'device',
|
||||
default: false,
|
||||
},
|
||||
aiChanMode: {
|
||||
where: 'device',
|
||||
default: false,
|
||||
@@ -343,15 +347,6 @@ export class ColdDeviceStorage {
|
||||
darkTheme,
|
||||
syncDeviceDarkMode: true,
|
||||
plugins: [] as Plugin[],
|
||||
mediaVolume: 0.5,
|
||||
sound_masterVolume: 0.5,
|
||||
sound_note: { type: 'syuilo/n-eca', volume: 0.5 },
|
||||
sound_noteMy: { type: 'syuilo/n-cea-4va', volume: 0.5 },
|
||||
sound_notification: { type: 'syuilo/n-ea', volume: 0.5 },
|
||||
sound_chat: { type: 'syuilo/pope1', volume: 0.5 },
|
||||
sound_chatBg: { type: 'syuilo/waon', volume: 0.5 },
|
||||
sound_antenna: { type: 'syuilo/triple', volume: 0.5 },
|
||||
sound_channel: { type: 'syuilo/square-pico', volume: 0.5 },
|
||||
};
|
||||
|
||||
public static watchers: Watcher[] = [];
|
||||
|
@@ -1,17 +1,18 @@
|
||||
import { post } from '@/os';
|
||||
import { api, post } from '@/os';
|
||||
import { $i, login } from '@/account';
|
||||
import { getAccountFromId } from '@/scripts/get-account-from-id';
|
||||
import { mainRouter } from '@/router';
|
||||
import { deepClone } from '@/scripts/clone';
|
||||
|
||||
export function swInject() {
|
||||
navigator.serviceWorker.addEventListener('message', ev => {
|
||||
navigator.serviceWorker.addEventListener('message', async ev => {
|
||||
if (_DEV_) {
|
||||
console.log('sw msg', ev.data);
|
||||
}
|
||||
|
||||
if (ev.data.type !== 'order') return;
|
||||
|
||||
if (ev.data.loginId !== $i?.id) {
|
||||
if (ev.data.loginId && ev.data.loginId !== $i?.id) {
|
||||
return getAccountFromId(ev.data.loginId).then(account => {
|
||||
if (!account) return;
|
||||
return login(account.token, ev.data.url);
|
||||
@@ -19,8 +20,18 @@ export function swInject() {
|
||||
}
|
||||
|
||||
switch (ev.data.order) {
|
||||
case 'post':
|
||||
return post(ev.data.options);
|
||||
case 'post': {
|
||||
const props = deepClone(ev.data.options);
|
||||
// プッシュ通知から来たreply,renoteはtruncateBodyが通されているため、
|
||||
// 完全なノートを取得しなおす
|
||||
if (props.reply) {
|
||||
props.reply = await api('notes/show', { noteId: props.reply.id });
|
||||
}
|
||||
if (props.renote) {
|
||||
props.renote = await api('notes/show', { noteId: props.renote.id });
|
||||
}
|
||||
return post(props);
|
||||
}
|
||||
case 'push':
|
||||
if (mainRouter.currentRoute.value.path === ev.data.url) {
|
||||
return window.scroll({ top: 0, behavior: 'smooth' });
|
||||
|
@@ -250,6 +250,7 @@ onMounted(() => {
|
||||
> .widgets {
|
||||
//--panelBorder: none;
|
||||
width: 300px;
|
||||
padding-bottom: calc(var(--margin) + env(safe-area-inset-bottom, 0px));
|
||||
|
||||
@media (max-width: $widgets-hide-threshold) {
|
||||
display: none;
|
||||
@@ -304,7 +305,7 @@ onMounted(() => {
|
||||
right: 0;
|
||||
z-index: 1001;
|
||||
height: 100dvh;
|
||||
padding: var(--margin);
|
||||
padding: var(--margin) var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
|
@@ -296,7 +296,7 @@ $widgets-hide-threshold: 1090px;
|
||||
}
|
||||
|
||||
.widgets {
|
||||
padding: 0 var(--margin);
|
||||
padding: 0 var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px));
|
||||
border-left: solid 0.5px var(--divider);
|
||||
background: var(--bg);
|
||||
|
||||
@@ -329,7 +329,7 @@ $widgets-hide-threshold: 1090px;
|
||||
right: 0;
|
||||
z-index: 1001;
|
||||
height: 100dvh;
|
||||
padding: var(--margin) !important;
|
||||
padding: var(--margin) var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px)) !important;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<XWidgets :class="$style.widgets" :edit="editMode" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
|
||||
|
||||
<button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button>
|
||||
<button v-else class="_textButton mk-widget-edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button>
|
||||
<button v-else class="_textButton mk-widget-edit" :class="$style.edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -91,4 +91,8 @@ function updateWidgets(thisWidgets) {
|
||||
.widgets {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.edit {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,22 +1,20 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
"node": false
|
||||
node: false,
|
||||
},
|
||||
parserOptions: {
|
||||
"parser": "@typescript-eslint/parser",
|
||||
parser: '@typescript-eslint/parser',
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
extends: [
|
||||
"../shared/.eslintrc.js",
|
||||
],
|
||||
extends: ['../shared/.eslintrc.js'],
|
||||
globals: {
|
||||
"require": false,
|
||||
"_DEV_": false,
|
||||
"_LANGS_": false,
|
||||
"_VERSION_": false,
|
||||
"_ENV_": false,
|
||||
"_PERF_PREFIX_": false,
|
||||
}
|
||||
}
|
||||
require: false,
|
||||
_DEV_: false,
|
||||
_LANGS_: false,
|
||||
_VERSION_: false,
|
||||
_ENV_: false,
|
||||
_PERF_PREFIX_: false,
|
||||
},
|
||||
};
|
||||
|
@@ -1,3 +1,5 @@
|
||||
// @ts-check
|
||||
|
||||
const esbuild = require('esbuild');
|
||||
const locales = require('../../locales');
|
||||
const meta = require('../../package.json');
|
||||
@@ -5,33 +7,36 @@ const watch = process.argv[2]?.includes('watch');
|
||||
|
||||
console.log('Starting SW building...');
|
||||
|
||||
esbuild.build({
|
||||
entryPoints: [ `${__dirname}/src/sw.ts` ],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
treeShaking: true,
|
||||
minify: process.env.NODE_ENV === 'production',
|
||||
/** @type {esbuild.BuildOptions} */
|
||||
const buildOptions = {
|
||||
absWorkingDir: __dirname,
|
||||
bundle: true,
|
||||
define: {
|
||||
_DEV_: JSON.stringify(process.env.NODE_ENV !== 'production'),
|
||||
_ENV_: JSON.stringify(process.env.NODE_ENV ?? ''), // `NODE_ENV`が`undefined`なとき`JSON.stringify`が`undefined`を返してエラーになってしまうので`??`を使っている
|
||||
_LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])),
|
||||
_PERF_PREFIX_: JSON.stringify('Misskey:'),
|
||||
_VERSION_: JSON.stringify(meta.version),
|
||||
},
|
||||
entryPoints: [`${__dirname}/src/sw.ts`],
|
||||
format: 'esm',
|
||||
loader: {
|
||||
'.ts': 'ts',
|
||||
},
|
||||
minify: process.env.NODE_ENV === 'production',
|
||||
outbase: `${__dirname}/src`,
|
||||
outdir: `${__dirname}/../../built/_sw_dist_`,
|
||||
loader: {
|
||||
'.ts': 'ts'
|
||||
},
|
||||
treeShaking: true,
|
||||
tsconfig: `${__dirname}/tsconfig.json`,
|
||||
define: {
|
||||
_VERSION_: JSON.stringify(meta.version),
|
||||
_LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])),
|
||||
_ENV_: JSON.stringify(process.env.NODE_ENV),
|
||||
_DEV_: process.env.NODE_ENV !== 'production',
|
||||
_PERF_PREFIX_: JSON.stringify('Misskey:'),
|
||||
},
|
||||
watch: watch ? {
|
||||
onRebuild(error, result) {
|
||||
if (error) console.error('SW: watch build failed:', error);
|
||||
else console.log('SW: watch build succeeded:', result);
|
||||
},
|
||||
} : false,
|
||||
}).then(result => {
|
||||
if (watch) console.log('watching...');
|
||||
else console.log('done,', JSON.stringify(result));
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
if (!watch) {
|
||||
await esbuild.build(buildOptions);
|
||||
console.log('done');
|
||||
} else {
|
||||
const context = await esbuild.context(buildOptions);
|
||||
await context.watch();
|
||||
console.log('watching...');
|
||||
}
|
||||
})();
|
||||
|
@@ -9,7 +9,7 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"esbuild": "0.14.42",
|
||||
"esbuild": "0.17.15",
|
||||
"idb-keyval": "6.2.0",
|
||||
"misskey-js": "workspace:*"
|
||||
},
|
||||
|
1
packages/sw/src/@types/global.d.ts
vendored
1
packages/sw/src/@types/global.d.ts
vendored
@@ -1,3 +1,4 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type FIXME = any;
|
||||
|
||||
declare const _LANGS_: string[][];
|
||||
|
@@ -1,14 +0,0 @@
|
||||
import * as misskey from 'misskey-js';
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
|
||||
export const acct = (user: misskey.Acct) => {
|
||||
return Acct.toString(user);
|
||||
};
|
||||
|
||||
export const userName = (user: misskey.entities.User) => {
|
||||
return user.name || user.username;
|
||||
};
|
||||
|
||||
export const userPage = (user: misskey.Acct, path?, absolute = false) => {
|
||||
return `${absolute ? origin : ''}/@${acct(user)}${(path ? `/${path}` : '')}`;
|
||||
};
|
@@ -1,31 +1,29 @@
|
||||
/*
|
||||
* Notification manager for SW
|
||||
*/
|
||||
import { swLang } from '@/scripts/lang';
|
||||
import { cli } from '@/scripts/operations';
|
||||
import { badgeNames, pushNotificationDataMap } from '@/types';
|
||||
import getUserName from '@/scripts/get-user-name';
|
||||
import { I18n } from '@/scripts/i18n';
|
||||
import { getAccountFromId } from '@/scripts/get-account-from-id';
|
||||
import type { BadgeNames, PushNotificationDataMap } from '@/types';
|
||||
import { char2fileName } from '@/scripts/twemoji-base';
|
||||
import * as url from '@/scripts/url';
|
||||
import { cli } from '@/scripts/operations';
|
||||
import { getAccountFromId } from '@/scripts/get-account-from-id';
|
||||
import { swLang } from '@/scripts/lang';
|
||||
import { getUserName } from '@/scripts/get-user-name';
|
||||
|
||||
const closeNotificationsByTags = async (tags: string[]) => {
|
||||
const closeNotificationsByTags = async (tags: string[]): Promise<void> => {
|
||||
for (const n of (await Promise.all(tags.map(tag => globalThis.registration.getNotifications({ tag })))).flat()) {
|
||||
n.close();
|
||||
}
|
||||
};
|
||||
|
||||
const iconUrl = (name: badgeNames) => `/static-assets/tabler-badges/${name}.png`;
|
||||
const iconUrl = (name: BadgeNames): string => `/static-assets/tabler-badges/${name}.png`;
|
||||
/* How to add a new badge:
|
||||
* 1. Find the icon and download png from https://tabler-icons.io/
|
||||
* 2. vips resize ~/Downloads/icon-name.png vipswork.png 0.4; vips scRGB2BW vipswork.png ~/icon-name.png"[compression=9,strip]"; rm vipswork.png;
|
||||
* 3. mv ~/icon-name.png ~/misskey/packages/backend/assets/tabler-badges/
|
||||
* 4. Add 'icon-name' to badgeNames
|
||||
* 4. Add 'icon-name' to BadgeNames
|
||||
* 5. Add `badge: iconUrl('icon-name'),`
|
||||
*/
|
||||
|
||||
export async function createNotification<K extends keyof pushNotificationDataMap>(data: pushNotificationDataMap[K]) {
|
||||
export async function createNotification<K extends keyof PushNotificationDataMap>(data: PushNotificationDataMap[K]): Promise<void> {
|
||||
const n = await composeNotification(data);
|
||||
|
||||
if (n) {
|
||||
@@ -36,9 +34,8 @@ export async function createNotification<K extends keyof pushNotificationDataMap
|
||||
}
|
||||
}
|
||||
|
||||
async function composeNotification(data: pushNotificationDataMap[keyof pushNotificationDataMap]): Promise<[string, NotificationOptions] | null> {
|
||||
if (!swLang.i18n) swLang.fetchLocale();
|
||||
const i18n = await swLang.i18n as I18n<any>;
|
||||
async function composeNotification(data: PushNotificationDataMap[keyof PushNotificationDataMap]): Promise<[string, NotificationOptions] | null> {
|
||||
const i18n = await (swLang.i18n ?? swLang.fetchLocale());
|
||||
const { t } = i18n;
|
||||
switch (data.type) {
|
||||
/*
|
||||
@@ -139,16 +136,16 @@ async function composeNotification(data: pushNotificationDataMap[keyof pushNotif
|
||||
if (reaction.startsWith(':')) {
|
||||
// カスタム絵文字の場合
|
||||
const name = reaction.substring(1, reaction.length - 1);
|
||||
badge = `${origin}/emoji/${name}.webp?${url.query({
|
||||
badge: '1',
|
||||
})}`;
|
||||
const badgeUrl = new URL(`/emoji/${name}.webp`, origin);
|
||||
badgeUrl.searchParams.set('badge', '1');
|
||||
badge = badgeUrl.href;
|
||||
reaction = name.split('@')[0];
|
||||
} else {
|
||||
// Unicode絵文字の場合
|
||||
badge = `/twemoji-badge/${char2fileName(reaction)}.png`;
|
||||
}
|
||||
|
||||
if (badge ? await fetch(badge).then(res => res.status !== 200).catch(() => true) : true) {
|
||||
if (await fetch(badge).then(res => res.status !== 200).catch(() => true)) {
|
||||
badge = iconUrl('plus');
|
||||
}
|
||||
|
||||
@@ -168,14 +165,6 @@ async function composeNotification(data: pushNotificationDataMap[keyof pushNotif
|
||||
}];
|
||||
}
|
||||
|
||||
case 'pollEnded':
|
||||
return [t('_notification.pollEnded'), {
|
||||
body: data.body.note.text || '',
|
||||
badge: iconUrl('chart-arrows'),
|
||||
tag: `poll:${data.body.note.id}`,
|
||||
data,
|
||||
}];
|
||||
|
||||
case 'receiveFollowRequest':
|
||||
return [t('_notification.youReceivedFollowRequest'), {
|
||||
body: getUserName(data.body.user),
|
||||
@@ -202,6 +191,14 @@ async function composeNotification(data: pushNotificationDataMap[keyof pushNotif
|
||||
data,
|
||||
}];
|
||||
|
||||
case 'achievementEarned':
|
||||
return [t('_notification.achievementEarned'), {
|
||||
body: t(`_achievements._types._${data.body.achievement}.title`),
|
||||
badge: iconUrl('medal'),
|
||||
data,
|
||||
tag: `achievement:${data.body.achievement}`,
|
||||
}];
|
||||
|
||||
case 'app':
|
||||
return [data.body.header ?? data.body.body, {
|
||||
body: data.body.header ? data.body.body : '',
|
||||
@@ -226,24 +223,35 @@ async function composeNotification(data: pushNotificationDataMap[keyof pushNotif
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEmptyNotification() {
|
||||
export async function createEmptyNotification(): Promise<void> {
|
||||
return new Promise<void>(async res => {
|
||||
if (!swLang.i18n) swLang.fetchLocale();
|
||||
const i18n = await swLang.i18n as I18n<any>;
|
||||
const i18n = await (swLang.i18n ?? swLang.fetchLocale());
|
||||
const { t } = i18n;
|
||||
|
||||
await globalThis.registration.showNotification(
|
||||
t('_notification.emptyPushNotificationMessage'),
|
||||
(new URL(origin)).host,
|
||||
{
|
||||
body: `Misskey v${_VERSION_}`,
|
||||
silent: true,
|
||||
badge: iconUrl('null'),
|
||||
tag: 'read_notification',
|
||||
actions: [
|
||||
{
|
||||
action: 'markAllAsRead',
|
||||
title: t('markAllAsRead'),
|
||||
},
|
||||
{
|
||||
action: 'settings',
|
||||
title: t('notificationSettings'),
|
||||
},
|
||||
],
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await closeNotificationsByTags(['user_visible_auto_notification', 'read_notification']);
|
||||
await closeNotificationsByTags(['user_visible_auto_notification']);
|
||||
} finally {
|
||||
res();
|
||||
}
|
||||
|
@@ -1,7 +1,10 @@
|
||||
import { get } from 'idb-keyval';
|
||||
|
||||
export async function getAccountFromId(id: string) {
|
||||
const accounts = await get('accounts') as { token: string; id: string; }[];
|
||||
if (!accounts) console.log('Accounts are not recorded');
|
||||
export async function getAccountFromId(id: string): Promise<{ token: string; id: string } | void> {
|
||||
const accounts = await get<{ token: string; id: string }[]>('accounts');
|
||||
if (!accounts) {
|
||||
console.log('Accounts are not recorded');
|
||||
return;
|
||||
}
|
||||
return accounts.find(e => e.id === id);
|
||||
}
|
||||
|
@@ -1,3 +1,3 @@
|
||||
export default function(user: { name?: string | null, username: string }): string {
|
||||
export function getUserName(user: { name?: string | null; username: string }): string {
|
||||
return user.name === '' ? user.username : user.name ?? user.username;
|
||||
}
|
||||
|
@@ -1,4 +1,6 @@
|
||||
export class I18n<T extends Record<string, any>> {
|
||||
export type Locale = { [key: string]: string | Locale };
|
||||
|
||||
export class I18n<T extends Locale = Locale> {
|
||||
public ts: T;
|
||||
|
||||
constructor(locale: T) {
|
||||
@@ -13,7 +15,8 @@ export class I18n<T extends Record<string, any>> {
|
||||
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
|
||||
public t(key: string, args?: Record<string, string>): string {
|
||||
try {
|
||||
let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string;
|
||||
let str = key.split('.').reduce<Locale | Locale[keyof Locale]>((o, i) => o[i], this.ts);
|
||||
if (typeof str !== 'string') throw new Error();
|
||||
|
||||
if (args) {
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
|
@@ -2,7 +2,7 @@
|
||||
* Language manager for SW
|
||||
*/
|
||||
import { get, set } from 'idb-keyval';
|
||||
import { I18n } from '@/scripts/i18n';
|
||||
import { I18n, type Locale } from '@/scripts/i18n';
|
||||
|
||||
class SwLang {
|
||||
public cacheName = `mk-cache-${_VERSION_}`;
|
||||
@@ -12,19 +12,19 @@ class SwLang {
|
||||
return prelang;
|
||||
});
|
||||
|
||||
public setLang(newLang: string) {
|
||||
public setLang(newLang: string): Promise<I18n<Locale>> {
|
||||
this.lang = Promise.resolve(newLang);
|
||||
set('lang', newLang);
|
||||
return this.fetchLocale();
|
||||
}
|
||||
|
||||
public i18n: Promise<I18n<any>> | null = null;
|
||||
public i18n: Promise<I18n> | null = null;
|
||||
|
||||
public fetchLocale() {
|
||||
return this.i18n = this._fetch();
|
||||
public fetchLocale(): Promise<I18n<Locale>> {
|
||||
return (this.i18n = this._fetch());
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
private async _fetch(): Promise<I18n<Locale>> {
|
||||
// Service Workerは何度も起動しそのたびにlocaleを読み込むので、CacheStorageを使う
|
||||
const localeUrl = `/assets/locales/${await this.lang}.${_VERSION_}.json`;
|
||||
let localeRes = await caches.match(localeUrl);
|
||||
@@ -32,13 +32,13 @@ class SwLang {
|
||||
// _DEV_がtrueの場合は常に最新化
|
||||
if (!localeRes || _DEV_) {
|
||||
localeRes = await fetch(localeUrl);
|
||||
const clone = localeRes?.clone();
|
||||
if (!clone?.clone().ok) Error('locale fetching error');
|
||||
const clone = localeRes.clone();
|
||||
if (!clone.clone().ok) throw new Error('locale fetching error');
|
||||
|
||||
caches.open(this.cacheName).then(cache => cache.put(localeUrl, clone));
|
||||
}
|
||||
|
||||
return new I18n(await localeRes.json());
|
||||
return new I18n<Locale>(await localeRes.json());
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,11 +1,5 @@
|
||||
export function getUrlWithLoginId(url: string, loginId: string) {
|
||||
export function getUrlWithLoginId(url: string, loginId: string): string {
|
||||
const u = new URL(url, origin);
|
||||
u.searchParams.append('loginId', loginId);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
export function getUrlWithoutLoginId(url: string) {
|
||||
const u = new URL(url);
|
||||
u.searchParams.delete('loginId');
|
||||
u.searchParams.set('loginId', loginId);
|
||||
return u.toString();
|
||||
}
|
||||
|
@@ -3,63 +3,77 @@
|
||||
* 各種操作
|
||||
*/
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { SwMessage, swMessageOrderType } from '@/types';
|
||||
import { acct as getAcct } from '@/filters/user';
|
||||
import type { SwMessage, SwMessageOrderType } from '@/types';
|
||||
import { getAccountFromId } from '@/scripts/get-account-from-id';
|
||||
import { getUrlWithLoginId } from '@/scripts/login-id';
|
||||
|
||||
export const cli = new Misskey.api.APIClient({ origin, fetch: (...args) => fetch(...args) });
|
||||
export const cli = new Misskey.api.APIClient({ origin, fetch: (...args): Promise<Response> => fetch(...args) });
|
||||
|
||||
export async function api<E extends keyof Misskey.Endpoints>(endpoint: E, userId: string, options?: Misskey.Endpoints[E]['req']) {
|
||||
const account = await getAccountFromId(userId);
|
||||
if (!account) return;
|
||||
export async function api<E extends keyof Misskey.Endpoints, O extends Misskey.Endpoints[E]['req']>(endpoint: E, userId?: string, options?: O): Promise<void | ReturnType<typeof cli.request<E, O>>> {
|
||||
let account: { token: string; id: string } | void;
|
||||
|
||||
return cli.request(endpoint, options, account.token);
|
||||
if (userId) {
|
||||
account = await getAccountFromId(userId);
|
||||
if (!account) return;
|
||||
}
|
||||
|
||||
return cli.request(endpoint, options, account?.token);
|
||||
}
|
||||
|
||||
// mark-all-as-read送出を1秒間隔に制限する
|
||||
const readBlockingStatus = new Map<string, boolean>();
|
||||
export function sendMarkAllAsRead(userId: string): Promise<null | undefined | void> {
|
||||
if (readBlockingStatus.get(userId)) return Promise.resolve();
|
||||
readBlockingStatus.set(userId, true);
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
readBlockingStatus.set(userId, false);
|
||||
api('notifications/mark-all-as-read', userId).then(resolve, resolve);
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// rendered acctからユーザーを開く
|
||||
export function openUser(acct: string, loginId: string) {
|
||||
export function openUser(acct: string, loginId?: string): ReturnType<typeof openClient> {
|
||||
return openClient('push', `/@${acct}`, loginId, { acct });
|
||||
}
|
||||
|
||||
// noteIdからノートを開く
|
||||
export function openNote(noteId: string, loginId: string) {
|
||||
export function openNote(noteId: string, loginId?: string): ReturnType<typeof openClient> {
|
||||
return openClient('push', `/notes/${noteId}`, loginId, { noteId });
|
||||
}
|
||||
|
||||
// noteIdからノートを開く
|
||||
export function openAntenna(antennaId: string, loginId: string) {
|
||||
export function openAntenna(antennaId: string, loginId: string): ReturnType<typeof openClient> {
|
||||
return openClient('push', `/timeline/antenna/${antennaId}`, loginId, { antennaId });
|
||||
}
|
||||
|
||||
// post-formのオプションから投稿フォームを開く
|
||||
export async function openPost(options: any, loginId: string) {
|
||||
export async function openPost(options: { initialText?: string; reply?: Misskey.entities.Note; renote?: Misskey.entities.Note }, loginId?: string): ReturnType<typeof openClient> {
|
||||
// クエリを作成しておく
|
||||
let url = '/share?';
|
||||
if (options.initialText) url += `text=${options.initialText}&`;
|
||||
if (options.reply) url += `replyId=${options.reply.id}&`;
|
||||
if (options.renote) url += `renoteId=${options.renote.id}&`;
|
||||
const url = '/share';
|
||||
const query = new URLSearchParams();
|
||||
if (options.initialText) query.set('text', options.initialText);
|
||||
if (options.reply) query.set('replyId', options.reply.id);
|
||||
if (options.renote) query.set('renoteId', options.renote.id);
|
||||
|
||||
return openClient('post', url, loginId, { options });
|
||||
return openClient('post', `${url}?${query}`, loginId, { options });
|
||||
}
|
||||
|
||||
export async function openClient(order: swMessageOrderType, url: string, loginId: string, query: any = {}) {
|
||||
export async function openClient(order: SwMessageOrderType, url: string, loginId?: string, query: Record<string, SwMessage[string]> = {}): Promise<WindowClient | null> {
|
||||
const client = await findClient();
|
||||
|
||||
if (client) {
|
||||
client.postMessage({ type: 'order', ...query, order, loginId, url } as SwMessage);
|
||||
client.postMessage({ type: 'order', ...query, order, loginId, url } satisfies SwMessage);
|
||||
return client;
|
||||
}
|
||||
|
||||
return globalThis.clients.openWindow(getUrlWithLoginId(url, loginId));
|
||||
return globalThis.clients.openWindow(loginId ? getUrlWithLoginId(url, loginId) : url);
|
||||
}
|
||||
|
||||
export async function findClient() {
|
||||
export async function findClient(): Promise<WindowClient | null> {
|
||||
const clients = await globalThis.clients.matchAll({
|
||||
type: 'window',
|
||||
});
|
||||
for (const c of clients) {
|
||||
if (!new URL(c.url).searchParams.has('zen')) return c;
|
||||
}
|
||||
return null;
|
||||
return clients.find(c => !(new URL(c.url)).searchParams.has('zen')) ?? null;
|
||||
}
|
||||
|
@@ -1,12 +1,8 @@
|
||||
export const twemojiSvgBase = '/twemoji';
|
||||
|
||||
export function char2fileName(char: string): string {
|
||||
let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16));
|
||||
let codes = Array.from(char)
|
||||
.map(x => x.codePointAt(0)?.toString(16))
|
||||
.filter(<T>(x: T | undefined): x is T => x !== undefined);
|
||||
if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
|
||||
codes = codes.filter(x => x && x.length);
|
||||
codes = codes.filter(x => x.length !== 0);
|
||||
return codes.join('-');
|
||||
}
|
||||
|
||||
export function char2filePath(char: string): string {
|
||||
return `${twemojiSvgBase}/${char2fileName(char)}.svg`;
|
||||
}
|
||||
|
@@ -1,18 +0,0 @@
|
||||
/* objを検査して
|
||||
* 1. 配列に何も入っていない時はクエリを付けない
|
||||
* 2. プロパティがundefinedの時はクエリを付けない
|
||||
* (new URLSearchParams(obj)ではそこまで丁寧なことをしてくれない)
|
||||
*/
|
||||
export function query(obj: object): string {
|
||||
const params = Object.entries(obj)
|
||||
.filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined)
|
||||
.reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>);
|
||||
|
||||
return Object.entries(params)
|
||||
.map((e) => `${e[0]}=${encodeURIComponent(e[1])}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
export function appendQuery(url: string, query: string): string {
|
||||
return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
|
||||
}
|
@@ -1,12 +1,12 @@
|
||||
import { get } from 'idb-keyval';
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import type { PushNotificationDataMap } from '@/types';
|
||||
import { createEmptyNotification, createNotification } from '@/scripts/create-notification';
|
||||
import { swLang } from '@/scripts/lang';
|
||||
import { api } from '@/scripts/operations';
|
||||
import { pushNotificationDataMap } from '@/types';
|
||||
import * as swos from '@/scripts/operations';
|
||||
import { acct as getAcct } from '@/filters/user';
|
||||
|
||||
globalThis.addEventListener('install', ev => {
|
||||
//ev.waitUntil(globalThis.skipWaiting());
|
||||
globalThis.addEventListener('install', () => {
|
||||
// ev.waitUntil(globalThis.skipWaiting());
|
||||
});
|
||||
|
||||
globalThis.addEventListener('activate', ev => {
|
||||
@@ -43,8 +43,8 @@ globalThis.addEventListener('push', ev => {
|
||||
ev.waitUntil(globalThis.clients.matchAll({
|
||||
includeUncontrolled: true,
|
||||
type: 'window',
|
||||
}).then(async (clients: readonly WindowClient[]) => {
|
||||
const data: pushNotificationDataMap[keyof pushNotificationDataMap] = ev.data?.json();
|
||||
}).then(async () => {
|
||||
const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.data?.json();
|
||||
|
||||
switch (data.type) {
|
||||
// case 'driveFileCreated':
|
||||
@@ -54,6 +54,10 @@ globalThis.addEventListener('push', ev => {
|
||||
if ((new Date()).getTime() - data.dateTime > 1000 * 60 * 60 * 24) break;
|
||||
|
||||
return createNotification(data);
|
||||
case 'readAllNotifications':
|
||||
await globalThis.registration.getNotifications()
|
||||
.then(notifications => notifications.forEach(n => n.close()));
|
||||
break;
|
||||
}
|
||||
|
||||
await createEmptyNotification();
|
||||
@@ -62,13 +66,13 @@ globalThis.addEventListener('push', ev => {
|
||||
});
|
||||
|
||||
globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEventMap['notificationclick']) => {
|
||||
ev.waitUntil((async () => {
|
||||
ev.waitUntil((async (): Promise<void> => {
|
||||
if (_DEV_) {
|
||||
console.log('notificationclick', ev.action, ev.notification.data);
|
||||
}
|
||||
|
||||
const { action, notification } = ev;
|
||||
const data: pushNotificationDataMap[keyof pushNotificationDataMap] = notification.data;
|
||||
const data: PushNotificationDataMap[keyof PushNotificationDataMap] = notification.data ?? {};
|
||||
const { userId: loginId } = data;
|
||||
let client: WindowClient | null = null;
|
||||
|
||||
@@ -79,7 +83,7 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv
|
||||
if ('userId' in data.body) await swos.api('following/create', loginId, { userId: data.body.userId });
|
||||
break;
|
||||
case 'showUser':
|
||||
if ('user' in data.body) client = await swos.openUser(getAcct(data.body.user), loginId);
|
||||
if ('user' in data.body) client = await swos.openUser(Acct.toString(data.body.user), loginId);
|
||||
break;
|
||||
case 'reply':
|
||||
if ('note' in data.body) client = await swos.openPost({ reply: data.body.note }, loginId);
|
||||
@@ -116,7 +120,7 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv
|
||||
if ('note' in data.body) {
|
||||
client = await swos.openNote(data.body.note.id, loginId);
|
||||
} else if ('user' in data.body) {
|
||||
client = await swos.openUser(getAcct(data.body.user), loginId);
|
||||
client = await swos.openUser(Acct.toString(data.body.user), loginId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -124,13 +128,29 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv
|
||||
break;
|
||||
case 'unreadAntennaNote':
|
||||
client = await swos.openAntenna(data.body.antenna.id, loginId);
|
||||
break;
|
||||
default:
|
||||
switch (action) {
|
||||
case 'markAllAsRead':
|
||||
await globalThis.registration.getNotifications()
|
||||
.then(notifications => notifications.forEach(n => n.close()));
|
||||
await get('accounts').then(accounts => {
|
||||
return Promise.all(accounts.map(async account => {
|
||||
await swos.sendMarkAllAsRead(account.id);
|
||||
}));
|
||||
});
|
||||
break;
|
||||
case 'settings':
|
||||
client = await swos.openClient('push', '/settings/notifications', loginId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (client) {
|
||||
client.focus();
|
||||
}
|
||||
if (data.type === 'notification') {
|
||||
api('notifications/mark-all-as-read', data.userId);
|
||||
await swos.sendMarkAllAsRead(loginId);
|
||||
}
|
||||
|
||||
notification.close();
|
||||
@@ -138,15 +158,18 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv
|
||||
});
|
||||
|
||||
globalThis.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEventMap['notificationclose']) => {
|
||||
const data: pushNotificationDataMap[keyof pushNotificationDataMap] = ev.notification.data;
|
||||
const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.notification.data;
|
||||
|
||||
if (data.type === 'notification') {
|
||||
api('notifications/mark-all-as-read', data.userId);
|
||||
}
|
||||
ev.waitUntil((async (): Promise<void> => {
|
||||
if (data.type === 'notification') {
|
||||
await swos.sendMarkAllAsRead(data.userId);
|
||||
}
|
||||
return;
|
||||
})());
|
||||
});
|
||||
|
||||
globalThis.addEventListener('message', (ev: ServiceWorkerGlobalScopeEventMap['message']) => {
|
||||
ev.waitUntil((async () => {
|
||||
ev.waitUntil((async (): Promise<void> => {
|
||||
switch (ev.data) {
|
||||
case 'clear':
|
||||
// Cache Storage全削除
|
||||
|
@@ -1,46 +1,47 @@
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type * as Misskey from 'misskey-js';
|
||||
|
||||
export type swMessageOrderType = 'post' | 'push';
|
||||
export type SwMessageOrderType = 'post' | 'push';
|
||||
|
||||
export type SwMessage = {
|
||||
type: 'order';
|
||||
order: swMessageOrderType;
|
||||
loginId: string;
|
||||
order: SwMessageOrderType;
|
||||
loginId?: string;
|
||||
url: string;
|
||||
[x: string]: any;
|
||||
[x: string]: unknown;
|
||||
};
|
||||
|
||||
// Defined also @/core/PushNotificationService.ts#L12
|
||||
type pushNotificationDataSourceMap = {
|
||||
type PushNotificationDataSourceMap = {
|
||||
notification: Misskey.entities.Notification;
|
||||
unreadAntennaNote: {
|
||||
antenna: { id: string, name: string };
|
||||
antenna: { id: string; name: string };
|
||||
note: Misskey.entities.Note;
|
||||
};
|
||||
readAllNotifications: undefined;
|
||||
};
|
||||
|
||||
export type pushNotificationData<K extends keyof pushNotificationDataSourceMap> = {
|
||||
export type PushNotificationData<K extends keyof PushNotificationDataSourceMap> = {
|
||||
type: K;
|
||||
body: pushNotificationDataSourceMap[K];
|
||||
body: PushNotificationDataSourceMap[K];
|
||||
userId: string;
|
||||
dateTime: number;
|
||||
};
|
||||
|
||||
export type pushNotificationDataMap = {
|
||||
[K in keyof pushNotificationDataSourceMap]: pushNotificationData<K>;
|
||||
export type PushNotificationDataMap = {
|
||||
[K in keyof PushNotificationDataSourceMap]: PushNotificationData<K>;
|
||||
};
|
||||
|
||||
export type badgeNames =
|
||||
'null'
|
||||
export type BadgeNames =
|
||||
| 'null'
|
||||
| 'antenna'
|
||||
| 'arrow-back-up'
|
||||
| 'at'
|
||||
| 'chart-arrows'
|
||||
| 'circle-check'
|
||||
| 'medal'
|
||||
| 'messages'
|
||||
| 'plus'
|
||||
| 'quote'
|
||||
| 'repeat'
|
||||
| 'user-plus'
|
||||
| 'users'
|
||||
;
|
||||
| 'users';
|
||||
|
Reference in New Issue
Block a user