commit
c5d2dba28d
20
CHANGELOG.md
20
CHANGELOG.md
@ -12,6 +12,26 @@
|
||||
|
||||
-->
|
||||
|
||||
## 13.11.3
|
||||
|
||||
### General
|
||||
- 指定したロールを持つユーザーのノートのみが流れるロールタイムラインを追加
|
||||
- Deckのカラムとしても追加可能
|
||||
- カスタム絵文字関連の改善
|
||||
* ノートなどに含まれるemojis(populateEmojiの結果)は(プロキシされたURLではなく)オリジナルのURLを指すように
|
||||
* MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように
|
||||
- カスタム絵文字でリアクションできないことがある問題を修正
|
||||
|
||||
### Client
|
||||
- チャンネルのピン留めされたノートの順番が正しくない問題を修正
|
||||
|
||||
### Server
|
||||
- フォローインポートなどでの大量のフォロー等操作をキューイングするように #10544 @nmkj-io
|
||||
- Misskey Webでのサーバーサイドエラー画面を改善
|
||||
- Misskey Webでのサーバーサイドエラーのログが残るように
|
||||
- ノート作成時のアンテナ追加パフォーマンスを改善
|
||||
- アンテナとロールTLのuntil/sinceプロパティが動くように
|
||||
|
||||
## 13.11.2
|
||||
|
||||
### Note
|
||||
|
@ -45,7 +45,7 @@ gulp.task('build:backend:script', () => {
|
||||
});
|
||||
|
||||
gulp.task('build:backend:style', () => {
|
||||
return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css'])
|
||||
return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css', './packages/backend/src/server/web/error.css'])
|
||||
.pipe(cssnano({
|
||||
zindex: false
|
||||
}))
|
||||
|
@ -20,6 +20,7 @@ noNotes: "Keine Notizen gefunden"
|
||||
noNotifications: "Keine Benachrichtigungen gefunden"
|
||||
instance: "Instanz"
|
||||
settings: "Einstellungen"
|
||||
notificationSettings: "Benachrichtigungseinstellungen"
|
||||
basicSettings: "Allgemeine Einstellungen"
|
||||
otherSettings: "Weitere Einstellungen"
|
||||
openInWindow: "In einem Fenster öffnen"
|
||||
@ -1407,6 +1408,8 @@ _channel:
|
||||
following: "Gefolgt"
|
||||
usersCount: "{n} Teilnehmer"
|
||||
notesCount: "{n} Notizen"
|
||||
nameAndDescription: "Name und Beschreibung"
|
||||
nameOnly: "Nur Name"
|
||||
_menuDisplay:
|
||||
sideFull: "Seitlich"
|
||||
sideIcon: "Seitlich (Icons)"
|
||||
@ -1887,6 +1890,7 @@ _deck:
|
||||
channel: "Kanal"
|
||||
mentions: "Erwähnungen"
|
||||
direct: "Direktnachrichten"
|
||||
roleTimeline: "Rollenchronik"
|
||||
_dialog:
|
||||
charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}"
|
||||
charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}"
|
||||
|
@ -20,6 +20,7 @@ noNotes: "No notes"
|
||||
noNotifications: "No notifications"
|
||||
instance: "Instance"
|
||||
settings: "Settings"
|
||||
notificationSettings: "Notification Settings"
|
||||
basicSettings: "Basic Settings"
|
||||
otherSettings: "Other Settings"
|
||||
openInWindow: "Open in window"
|
||||
@ -1407,6 +1408,8 @@ _channel:
|
||||
following: "Followed"
|
||||
usersCount: "{n} Participants"
|
||||
notesCount: "{n} Notes"
|
||||
nameAndDescription: "Name and description"
|
||||
nameOnly: "Name only"
|
||||
_menuDisplay:
|
||||
sideFull: "Side"
|
||||
sideIcon: "Side (Icons)"
|
||||
@ -1887,6 +1890,7 @@ _deck:
|
||||
channel: "Channel"
|
||||
mentions: "Mentions"
|
||||
direct: "Direct notes"
|
||||
roleTimeline: "Role Timeline"
|
||||
_dialog:
|
||||
charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}."
|
||||
charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}."
|
||||
|
@ -20,6 +20,7 @@ noNotes: "Nessuna nota!"
|
||||
noNotifications: "Nessuna notifica"
|
||||
instance: "Istanza"
|
||||
settings: "Impostazioni"
|
||||
notificationSettings: "Preferenze di notifica"
|
||||
basicSettings: "Impostazioni generali"
|
||||
otherSettings: "Altre impostazioni"
|
||||
openInWindow: "Apri in una finestra"
|
||||
@ -786,7 +787,7 @@ gallery: "Galleria"
|
||||
recentPosts: "Le più recenti"
|
||||
popularPosts: "Le più visualizzate"
|
||||
shareWithNote: "Condividere in nota"
|
||||
ads: "Pubblicità"
|
||||
ads: "Banner"
|
||||
expiration: "Scadenza"
|
||||
startingperiod: "Periodo di inizio"
|
||||
memo: "Promemoria"
|
||||
@ -991,6 +992,7 @@ largeNoteReactions: "Ingrandisci le reazioni"
|
||||
noteIdOrUrl: "ID della Nota o URL"
|
||||
accountMigration: "Migrazione del profilo"
|
||||
accountMoved: "Questo profilo ha migrato altrove:"
|
||||
forceShowAds: "Mostra sempre i banner"
|
||||
_accountMigration:
|
||||
moveTo: "Migrare questo profilo verso un un altro"
|
||||
moveToLabel: "Profilo verso cui migrare"
|
||||
@ -1406,6 +1408,8 @@ _channel:
|
||||
following: "Seguiti"
|
||||
usersCount: "{n} partecipanti"
|
||||
notesCount: "{n} note"
|
||||
nameAndDescription: "Nome e descrizione"
|
||||
nameOnly: "Solo il nome"
|
||||
_menuDisplay:
|
||||
sideFull: "Laterale"
|
||||
sideIcon: "Laterale (solo icone)"
|
||||
|
@ -1943,6 +1943,7 @@ _deck:
|
||||
channel: "チャンネル"
|
||||
mentions: "あなた宛て"
|
||||
direct: "ダイレクト"
|
||||
roleTimeline: "ロールタイムライン"
|
||||
|
||||
_dialog:
|
||||
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
|
||||
|
@ -20,6 +20,7 @@ noNotes: "ノートはあらへん"
|
||||
noNotifications: "通知はあらへん"
|
||||
instance: "サーバー"
|
||||
settings: "設定"
|
||||
notificationSettings: "通知の設定"
|
||||
basicSettings: "基本設定"
|
||||
otherSettings: "ほかの設定"
|
||||
openInWindow: "ウィンドウで開くで"
|
||||
@ -1407,6 +1408,8 @@ _channel:
|
||||
following: "フォロー中やで"
|
||||
usersCount: "{n}人が参加中やで"
|
||||
notesCount: "{n}こ投稿があるで"
|
||||
nameAndDescription: "名前と説明"
|
||||
nameOnly: "名前だけ"
|
||||
_menuDisplay:
|
||||
sideFull: "横"
|
||||
sideIcon: "横(アイコン)"
|
||||
@ -1887,6 +1890,7 @@ _deck:
|
||||
channel: "チャンネル"
|
||||
mentions: "あんた宛て"
|
||||
direct: "ダイレクト"
|
||||
roleTimeline: "ロールタイムライン"
|
||||
_dialog:
|
||||
charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}"
|
||||
charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}"
|
||||
|
@ -20,6 +20,7 @@ noNotes: "没有帖文"
|
||||
noNotifications: "无通知"
|
||||
instance: "服务器"
|
||||
settings: "设置"
|
||||
notificationSettings: "通知设置"
|
||||
basicSettings: "基本设置"
|
||||
otherSettings: "其他设置"
|
||||
openInWindow: "在新窗口中打开"
|
||||
@ -1407,6 +1408,8 @@ _channel:
|
||||
following: "正在关注"
|
||||
usersCount: "有{n}人参与"
|
||||
notesCount: "有{n}个帖子"
|
||||
nameAndDescription: "名称与描述"
|
||||
nameOnly: "仅名称"
|
||||
_menuDisplay:
|
||||
sideFull: "横向"
|
||||
sideIcon: "横向(图标)"
|
||||
@ -1887,6 +1890,7 @@ _deck:
|
||||
channel: "频道"
|
||||
mentions: "提及"
|
||||
direct: "指定用户"
|
||||
roleTimeline: "角色时间线"
|
||||
_dialog:
|
||||
charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}"
|
||||
charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}"
|
||||
|
@ -20,6 +20,7 @@ noNotes: "無貼文。"
|
||||
noNotifications: "沒有通知"
|
||||
instance: "實例"
|
||||
settings: "設定"
|
||||
notificationSettings: "通知選項"
|
||||
basicSettings: "基本設定"
|
||||
otherSettings: "其他設定"
|
||||
openInWindow: "在新視窗開啟"
|
||||
@ -506,6 +507,7 @@ objectStorageUseSSLDesc: "如果不使用https進行API連接,請關閉"
|
||||
objectStorageUseProxy: "使用網路代理"
|
||||
objectStorageUseProxyDesc: "如果不使用代理進行API連接,請關閉"
|
||||
objectStorageSetPublicRead: "上傳時設定為\"public-read\""
|
||||
s3ForcePathStyleDesc: "啟用 s3ForcePathStyle 會強制將儲存槽名稱指定為 URL 中路徑的一部分,而不是主機名。 使用自託管 Minio 之類的可能需要啟用。"
|
||||
serverLogs: "伺服器日誌"
|
||||
deleteAll: "刪除所有記錄"
|
||||
showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框"
|
||||
@ -560,7 +562,7 @@ inboxUrl: "收件夾URL"
|
||||
addedRelays: "已加入的中繼"
|
||||
serviceworkerInfo: "您需要啟用推送通知"
|
||||
deletedNote: "已删除的貼文"
|
||||
invisibleNote: "隱藏的貼文"
|
||||
invisibleNote: "私密的貼文"
|
||||
enableInfiniteScroll: "啟用自動滾動頁面模式"
|
||||
visibility: "可見性"
|
||||
poll: "投票"
|
||||
@ -919,6 +921,7 @@ pushNotificationNotSupported: "瀏覽器或實例不支援推播通知"
|
||||
sendPushNotificationReadMessage: "通知與訊息如果已讀的話,就將推播通知刪除"
|
||||
sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通知將立刻顯示。可能會增加設備的電池消耗。"
|
||||
windowMaximize: "最大化"
|
||||
windowMinimize: "最小化"
|
||||
windowRestore: "復原"
|
||||
caption: "標題"
|
||||
loggedInAsBot: "以機器人帳戶登入中"
|
||||
@ -960,6 +963,9 @@ copyErrorInfo: "複製錯誤資訊"
|
||||
joinThisServer: "在此伺服器上註冊"
|
||||
exploreOtherServers: "探索其他伺服器"
|
||||
letsLookAtTimeline: "看看時間軸"
|
||||
disableFederationConfirm: "要停止聯邦功能嗎?"
|
||||
disableFederationConfirmWarn: "即使停止了聯邦功能,貼文也不會變成私密的。在大部分的情況下,沒有必要停止聯邦功能。"
|
||||
disableFederationOk: "停止聯邦功能"
|
||||
invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。"
|
||||
emailNotSupported: "這個伺服器不支援寄送郵件"
|
||||
postToTheChannel: "發布到頻道"
|
||||
@ -985,6 +991,7 @@ showClipButtonInNoteFooter: "將摘錄添加至貼文"
|
||||
largeNoteReactions: "將貼文的反應放大顯示"
|
||||
noteIdOrUrl: "貼文ID或URL"
|
||||
accountMigration: "遷移帳戶"
|
||||
accountMoved: "這個使用者已遷移至新的帳戶:"
|
||||
forceShowAds: "總是顯示廣告"
|
||||
_accountMigration:
|
||||
moveTo: "將這個帳戶遷移至新的帳戶"
|
||||
@ -1401,6 +1408,8 @@ _channel:
|
||||
following: "關注中"
|
||||
usersCount: "有{n}人參與"
|
||||
notesCount: "有{n}個貼文"
|
||||
nameAndDescription: "名稱與說明"
|
||||
nameOnly: "僅名稱"
|
||||
_menuDisplay:
|
||||
sideFull: "側向"
|
||||
sideIcon: "側向(圖示)"
|
||||
@ -1881,6 +1890,7 @@ _deck:
|
||||
channel: "頻道"
|
||||
mentions: "提及"
|
||||
direct: "指定使用者"
|
||||
roleTimeline: "角色時間軸"
|
||||
_dialog:
|
||||
charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}"
|
||||
charactersBelow: "低於最少字數!現在 {current} / 限制 {max}"
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "13.11.2",
|
||||
"version": "13.11.3",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -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',
|
||||
'*',
|
||||
'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: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
||||
|
@ -13,6 +13,7 @@ import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { query } from '@/misc/prelude/url.js';
|
||||
import type { Serialized } from '@/server/api/stream/types.js';
|
||||
|
||||
@Injectable()
|
||||
export class CustomEmojiService {
|
||||
@ -44,7 +45,13 @@ export class CustomEmojiService {
|
||||
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(Array.from(value.values())),
|
||||
fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換
|
||||
fromRedisConverter: (value) => {
|
||||
if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す)
|
||||
return new Map(JSON.parse(value).map((x: Serialized<Emoji>) => [x.name, {
|
||||
...x,
|
||||
updatedAt: x.updatedAt ? new Date(x.updatedAt) : null,
|
||||
}]));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -267,16 +274,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なので??はだめ)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -14,11 +14,13 @@ import type {
|
||||
MainStreamTypes,
|
||||
NoteStreamTypes,
|
||||
UserListStreamTypes,
|
||||
RoleTimelineStreamTypes,
|
||||
} from '@/server/api/stream/types.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { Role } from '@/models';
|
||||
|
||||
@Injectable()
|
||||
export class GlobalEventService {
|
||||
@ -81,6 +83,11 @@ export class GlobalEventService {
|
||||
this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishRoleTimelineStream<K extends keyof RoleTimelineStreamTypes>(roleId: Role['id'], type: K, value?: RoleTimelineStreamTypes[K]): void {
|
||||
this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishNotesStream(note: Packed<'Note'>): void {
|
||||
this.publish('notesStream', null, note);
|
||||
|
@ -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);
|
||||
@ -554,6 +547,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
|
||||
this.globalEventService.publishNotesStream(noteObj);
|
||||
|
||||
this.roleService.addNoteToRoleTimeline(noteObj);
|
||||
|
||||
this.webhookService.getActiveWebhooks().then(webhooks => {
|
||||
webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
|
||||
for (const webhook of webhooks) {
|
||||
|
@ -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`);
|
||||
});
|
||||
|
@ -13,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { StreamMessages } from '@/server/api/stream/types.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { Packed } from '@/misc/json-schema';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
export type RolePolicies = {
|
||||
@ -64,6 +65,9 @@ export class RoleService implements OnApplicationShutdown {
|
||||
public static NotAssignedError = class extends Error {};
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@ -398,6 +402,25 @@ export class RoleService implements OnApplicationShutdown {
|
||||
this.globalEventService.publishInternalEvent('userRoleUnassigned', existing);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise<void> {
|
||||
const roles = await this.getUserRoles(note.userId);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
for (const role of roles) {
|
||||
redisPipeline.xadd(
|
||||
`roleTimeline:${role.id}`,
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
|
||||
}
|
||||
|
||||
redisPipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
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 => {
|
||||
|
@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import Logger from '../logger.js';
|
||||
|
||||
const logger = new Logger('following/create');
|
||||
@ -43,7 +44,10 @@ export class UserFollowingService implements OnModuleInit {
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
|
||||
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@ -79,7 +83,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 +143,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 +159,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 +238,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 => {
|
||||
@ -410,7 +415,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee));
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, requestId ?? `${this.config.url}/follows/${followRequest.id}`));
|
||||
this.queueService.deliver(follower, content, followee.inbox, false);
|
||||
}
|
||||
}
|
||||
|
@ -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 } }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ export class ChannelEntityService {
|
||||
} : {}),
|
||||
|
||||
...(detailed ? {
|
||||
pinnedNotes: await this.noteEntityService.packMany(pinnedNotes, me),
|
||||
pinnedNotes: (await this.noteEntityService.packMany(pinnedNotes, me)).sort((a, b) => channel.pinnedNoteIds.indexOf(a.id) - channel.pinnedNoteIds.indexOf(b.id)),
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
private fromRedisConverter: (value: string) => T | undefined;
|
||||
|
||||
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;
|
||||
private fromRedisConverter: (value: string) => T | undefined;
|
||||
|
||||
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 = {
|
||||
|
@ -6,7 +6,7 @@ import { Brackets, In, IsNull, LessThan, Not } from 'typeorm';
|
||||
import accepts from 'accepts';
|
||||
import vary from 'vary';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/index.js';
|
||||
import * as url from '@/misc/prelude/url.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
@ -54,6 +54,9 @@ export class ActivityPubServerService {
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private userEntityService: UserEntityService,
|
||||
private apRendererService: ApRendererService,
|
||||
@ -205,22 +208,22 @@ export class ActivityPubServerService {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const page = request.query.page === 'true';
|
||||
|
||||
|
||||
const user = await this.usersRepository.findOneBy({
|
||||
id: userId,
|
||||
host: IsNull(),
|
||||
});
|
||||
|
||||
|
||||
if (user == null) {
|
||||
reply.code(404);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
//#region Check ff visibility
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
|
||||
|
||||
if (profile.ffVisibility === 'private') {
|
||||
reply.code(403);
|
||||
reply.header('Cache-Control', 'public, max-age=30');
|
||||
@ -231,31 +234,31 @@ export class ActivityPubServerService {
|
||||
return;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
||||
const limit = 10;
|
||||
const partOf = `${this.config.url}/users/${userId}/following`;
|
||||
|
||||
|
||||
if (page) {
|
||||
const query = {
|
||||
followerId: user.id,
|
||||
} as FindOptionsWhere<Following>;
|
||||
|
||||
|
||||
// カーソルが指定されている場合
|
||||
if (cursor) {
|
||||
query.id = LessThan(cursor);
|
||||
}
|
||||
|
||||
|
||||
// Get followings
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: query,
|
||||
take: limit + 1,
|
||||
order: { id: -1 },
|
||||
});
|
||||
|
||||
|
||||
// 「次のページ」があるかどうか
|
||||
const inStock = followings.length === limit + 1;
|
||||
if (inStock) followings.pop();
|
||||
|
||||
|
||||
const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId)));
|
||||
const rendered = this.apRendererService.renderOrderedCollectionPage(
|
||||
`${partOf}?${url.query({
|
||||
@ -269,7 +272,7 @@ export class ActivityPubServerService {
|
||||
cursor: followings[followings.length - 1].id,
|
||||
})}` : undefined,
|
||||
);
|
||||
|
||||
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(rendered));
|
||||
} else {
|
||||
@ -330,33 +333,33 @@ export class ActivityPubServerService {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const untilId = request.query.until_id;
|
||||
if (untilId != null && typeof untilId !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const page = request.query.page === 'true';
|
||||
|
||||
|
||||
if (countIf(x => x != null, [sinceId, untilId]) > 1) {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const user = await this.usersRepository.findOneBy({
|
||||
id: userId,
|
||||
host: IsNull(),
|
||||
});
|
||||
|
||||
|
||||
if (user == null) {
|
||||
reply.code(404);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const limit = 20;
|
||||
const partOf = `${this.config.url}/users/${userId}/outbox`;
|
||||
|
||||
|
||||
if (page) {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
|
||||
.andWhere('note.userId = :userId', { userId: user.id })
|
||||
@ -365,11 +368,11 @@ export class ActivityPubServerService {
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
.andWhere('note.localOnly = FALSE');
|
||||
|
||||
|
||||
const notes = await query.take(limit).getMany();
|
||||
|
||||
|
||||
if (sinceId) notes.reverse();
|
||||
|
||||
|
||||
const activities = await Promise.all(notes.map(note => this.packActivity(note)));
|
||||
const rendered = this.apRendererService.renderOrderedCollectionPage(
|
||||
`${partOf}?${url.query({
|
||||
@ -387,7 +390,7 @@ export class ActivityPubServerService {
|
||||
until_id: notes[notes.length - 1].id,
|
||||
})}` : undefined,
|
||||
);
|
||||
|
||||
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(rendered));
|
||||
} else {
|
||||
@ -457,7 +460,7 @@ export class ActivityPubServerService {
|
||||
// note
|
||||
fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
|
||||
vary(reply.raw, 'Accept');
|
||||
|
||||
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
id: request.params.note,
|
||||
visibility: In(['public', 'home']),
|
||||
@ -639,6 +642,41 @@ export class ActivityPubServerService {
|
||||
return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)));
|
||||
});
|
||||
|
||||
// follow
|
||||
fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => {
|
||||
// This may be used before the follow is completed, so we do not
|
||||
// check if the following exists and only check if the follow request exists.
|
||||
|
||||
const followRequest = await this.followRequestsRepository.findOneBy({
|
||||
id: request.params.followRequestId,
|
||||
});
|
||||
|
||||
if (followRequest == null) {
|
||||
reply.code(404);
|
||||
return;
|
||||
}
|
||||
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneBy({
|
||||
id: followRequest.followerId,
|
||||
host: IsNull(),
|
||||
}),
|
||||
this.usersRepository.findOneBy({
|
||||
id: followRequest.followeeId,
|
||||
host: Not(IsNull()),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (follower == null || followee == null) {
|
||||
reply.code(404);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.header('Cache-Control', 'public, max-age=180');
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)));
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
@ -34,6 +34,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
|
||||
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
|
||||
import { UserListChannelService } from './api/stream/channels/user-list.js';
|
||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||
import { ClientLoggerService } from './web/ClientLoggerService.js';
|
||||
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -42,6 +44,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||
],
|
||||
providers: [
|
||||
ClientServerService,
|
||||
ClientLoggerService,
|
||||
FeedService,
|
||||
UrlPreviewService,
|
||||
ActivityPubServerService,
|
||||
@ -67,6 +70,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||
DriveChannelService,
|
||||
GlobalTimelineChannelService,
|
||||
HashtagChannelService,
|
||||
RoleTimelineChannelService,
|
||||
HomeTimelineChannelService,
|
||||
HybridTimelineChannelService,
|
||||
LocalTimelineChannelService,
|
||||
|
@ -294,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js';
|
||||
import * as ep___roles_list from './endpoints/roles/list.js';
|
||||
import * as ep___roles_show from './endpoints/roles/show.js';
|
||||
import * as ep___roles_users from './endpoints/roles/users.js';
|
||||
import * as ep___roles_notes from './endpoints/roles/notes.js';
|
||||
import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
|
||||
import * as ep___resetDb from './endpoints/reset-db.js';
|
||||
import * as ep___resetPassword from './endpoints/reset-password.js';
|
||||
@ -628,6 +629,7 @@ const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_r
|
||||
const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default };
|
||||
const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default };
|
||||
const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default };
|
||||
const $roles_notes: Provider = { provide: 'ep:roles/notes', useClass: ep___roles_notes.default };
|
||||
const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default };
|
||||
const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default };
|
||||
const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default };
|
||||
@ -966,6 +968,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$roles_list,
|
||||
$roles_show,
|
||||
$roles_users,
|
||||
$roles_notes,
|
||||
$requestResetPassword,
|
||||
$resetDb,
|
||||
$resetPassword,
|
||||
@ -1298,6 +1301,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$roles_list,
|
||||
$roles_show,
|
||||
$roles_users,
|
||||
$roles_notes,
|
||||
$requestResetPassword,
|
||||
$resetDb,
|
||||
$resetPassword,
|
||||
|
@ -294,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js';
|
||||
import * as ep___roles_list from './endpoints/roles/list.js';
|
||||
import * as ep___roles_show from './endpoints/roles/show.js';
|
||||
import * as ep___roles_users from './endpoints/roles/users.js';
|
||||
import * as ep___roles_notes from './endpoints/roles/notes.js';
|
||||
import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
|
||||
import * as ep___resetDb from './endpoints/reset-db.js';
|
||||
import * as ep___resetPassword from './endpoints/reset-password.js';
|
||||
@ -626,6 +627,7 @@ const eps = [
|
||||
['roles/list', ep___roles_list],
|
||||
['roles/show', ep___roles_show],
|
||||
['roles/users', ep___roles_users],
|
||||
['roles/notes', ep___roles_notes],
|
||||
['request-reset-password', ep___requestResetPassword],
|
||||
['reset-db', ep___resetDb],
|
||||
['reset-password', ep___resetPassword],
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -76,17 +76,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
throw new ApiError(meta.errors.noSuchAntenna);
|
||||
}
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
const noteIdsRes = await this.redisClient.xrevrange(
|
||||
`antennaTimeline:${antenna.id}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
|
||||
'-',
|
||||
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit);
|
||||
|
||||
if (noteIdsRes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
|
@ -91,11 +91,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
const notificationsRes = await this.redisClient.xrevrange(
|
||||
`notificationTimeline:${me.id}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
|
||||
'-',
|
||||
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
|
||||
'COUNT', limit);
|
||||
|
||||
if (notificationsRes.length === 0) {
|
||||
return [];
|
||||
|
109
packages/backend/src/server/api/endpoints/roles/notes.ts
Normal file
109
packages/backend/src/server/api/endpoints/roles/notes.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { NotesRepository, RolesRepository } from '@/models/index.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['role', 'notes'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
errors: {
|
||||
noSuchRole: {
|
||||
message: 'No such role.',
|
||||
code: 'NO_SUCH_ROLE',
|
||||
id: 'eb70323a-df61-4dd4-ad90-89c83c7cf26e',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Note',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roleId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
},
|
||||
required: ['roleId'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.rolesRepository)
|
||||
private rolesRepository: RolesRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const role = await this.rolesRepository.findOneBy({
|
||||
id: ps.roleId,
|
||||
});
|
||||
|
||||
if (role == null) {
|
||||
throw new ApiError(meta.errors.noSuchRole);
|
||||
}
|
||||
|
||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
const noteIdsRes = await this.redisClient.xrevrange(
|
||||
`roleTimeline:${role.id}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||
'COUNT', limit);
|
||||
|
||||
if (noteIdsRes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
this.queryService.generateBlockedUserQuery(query, me);
|
||||
|
||||
const notes = await query.getMany();
|
||||
notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
||||
return await this.noteEntityService.packMany(notes, me);
|
||||
});
|
||||
}
|
||||
}
|
@ -41,8 +41,6 @@ export const paramDef = {
|
||||
],
|
||||
} as const;
|
||||
|
||||
// TODO: avatar,bannerをJOINしたいけどエラーになる
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
@ -13,6 +13,7 @@ import { UserListChannelService } from './channels/user-list.js';
|
||||
import { AntennaChannelService } from './channels/antenna.js';
|
||||
import { DriveChannelService } from './channels/drive.js';
|
||||
import { HashtagChannelService } from './channels/hashtag.js';
|
||||
import { RoleTimelineChannelService } from './channels/role-timeline.js';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelsService {
|
||||
@ -24,6 +25,7 @@ export class ChannelsService {
|
||||
private globalTimelineChannelService: GlobalTimelineChannelService,
|
||||
private userListChannelService: UserListChannelService,
|
||||
private hashtagChannelService: HashtagChannelService,
|
||||
private roleTimelineChannelService: RoleTimelineChannelService,
|
||||
private antennaChannelService: AntennaChannelService,
|
||||
private channelChannelService: ChannelChannelService,
|
||||
private driveChannelService: DriveChannelService,
|
||||
@ -43,6 +45,7 @@ export class ChannelsService {
|
||||
case 'globalTimeline': return this.globalTimelineChannelService;
|
||||
case 'userList': return this.userListChannelService;
|
||||
case 'hashtag': return this.hashtagChannelService;
|
||||
case 'roleTimeline': return this.roleTimelineChannelService;
|
||||
case 'antenna': return this.antennaChannelService;
|
||||
case 'channel': return this.channelChannelService;
|
||||
case 'drive': return this.driveChannelService;
|
||||
|
@ -0,0 +1,75 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import Channel from '../channel.js';
|
||||
import { StreamMessages } from '../types.js';
|
||||
|
||||
class RoleTimelineChannel extends Channel {
|
||||
public readonly chName = 'roleTimeline';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = false;
|
||||
private roleId: string;
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
) {
|
||||
super(id, connection);
|
||||
//this.onNote = this.onNote.bind(this);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async init(params: any) {
|
||||
this.roleId = params.roleId as string;
|
||||
|
||||
this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onEvent(data: StreamMessages['roleTimeline']['payload']) {
|
||||
if (data.type === 'note') {
|
||||
const note = data.body;
|
||||
|
||||
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
|
||||
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
|
||||
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
|
||||
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
|
||||
|
||||
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
|
||||
|
||||
this.send('note', note);
|
||||
} else {
|
||||
this.send(data.type, data.body);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose() {
|
||||
// Unsubscribe events
|
||||
this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RoleTimelineChannelService {
|
||||
public readonly shouldShare = RoleTimelineChannel.shouldShare;
|
||||
public readonly requireCredential = RoleTimelineChannel.requireCredential;
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): RoleTimelineChannel {
|
||||
return new RoleTimelineChannel(
|
||||
this.noteEntityService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
@ -148,6 +148,10 @@ export interface AntennaStreamTypes {
|
||||
note: Note;
|
||||
}
|
||||
|
||||
export interface RoleTimelineStreamTypes {
|
||||
note: Packed<'Note'>;
|
||||
}
|
||||
|
||||
export interface AdminStreamTypes {
|
||||
newAbuseUserReport: {
|
||||
id: AbuseUserReport['id'];
|
||||
@ -168,7 +172,7 @@ type EventUnionFromDictionary<
|
||||
> = U[keyof U];
|
||||
|
||||
// redis通すとDateのインスタンスはstringに変換されるので
|
||||
type Serialized<T> = {
|
||||
export type Serialized<T> = {
|
||||
[K in keyof T]:
|
||||
T[K] extends Date
|
||||
? string
|
||||
@ -209,6 +213,10 @@ export type StreamMessages = {
|
||||
name: `userListStream:${UserList['id']}`;
|
||||
payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>;
|
||||
};
|
||||
roleTimeline: {
|
||||
name: `roleTimelineStream:${Role['id']}`;
|
||||
payload: EventUnionFromDictionary<SerializedAll<RoleTimelineStreamTypes>>;
|
||||
};
|
||||
antenna: {
|
||||
name: `antennaStream:${Antenna['id']}`;
|
||||
payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>;
|
||||
|
14
packages/backend/src/server/web/ClientLoggerService.ts
Normal file
14
packages/backend/src/server/web/ClientLoggerService.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type Logger from '@/logger.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ClientLoggerService {
|
||||
public logger: Logger;
|
||||
|
||||
constructor(
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('client');
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { createBullBoard } from '@bull-board/api';
|
||||
import { BullAdapter } from '@bull-board/api/bullAdapter.js';
|
||||
import { FastifyAdapter } from '@bull-board/fastify';
|
||||
@ -26,6 +27,7 @@ import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityServi
|
||||
import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
|
||||
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
|
||||
import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { deepClone } from '@/misc/clone.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
|
||||
@ -34,6 +36,7 @@ import manifest from './manifest.json' assert { type: 'json' };
|
||||
import { FeedService } from './FeedService.js';
|
||||
import { UrlPreviewService } from './UrlPreviewService.js';
|
||||
import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify';
|
||||
import { ClientLoggerService } from './ClientLoggerService.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
@ -46,6 +49,8 @@ const viteOut = `${_dirname}/../../../../../built/_vite_/`;
|
||||
|
||||
@Injectable()
|
||||
export class ClientServerService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
@ -85,6 +90,7 @@ export class ClientServerService {
|
||||
private urlPreviewService: UrlPreviewService,
|
||||
private feedService: FeedService,
|
||||
private roleService: RoleService,
|
||||
private clientLoggerService: ClientLoggerService,
|
||||
|
||||
@Inject('queue:system') public systemQueue: SystemQueue,
|
||||
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
|
||||
@ -649,6 +655,24 @@ export class ClientServerService {
|
||||
return await renderBase(reply);
|
||||
});
|
||||
|
||||
fastify.setErrorHandler(async (error, request, reply) => {
|
||||
const errId = uuid();
|
||||
this.clientLoggerService.logger.error(`Internal error occured in ${request.routerPath}: ${error.message}`, {
|
||||
path: request.routerPath,
|
||||
params: request.params,
|
||||
query: request.query,
|
||||
code: error.name,
|
||||
stack: error.stack,
|
||||
id: errId,
|
||||
});
|
||||
reply.code(500);
|
||||
reply.header('Cache-Control', 'max-age=10, must-revalidate');
|
||||
return await reply.view('error', {
|
||||
code: error.code,
|
||||
id: errId,
|
||||
});
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
110
packages/backend/src/server/web/error.css
Normal file
110
packages/backend/src/server/web/error.css
Normal file
@ -0,0 +1,110 @@
|
||||
* {
|
||||
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
|
||||
}
|
||||
|
||||
#misskey_app,
|
||||
#splash {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
background-color: #222;
|
||||
color: #dfddcc;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 999px;
|
||||
padding: 0px 12px 0px 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.button-big {
|
||||
background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
|
||||
line-height: 50px;
|
||||
}
|
||||
|
||||
.button-big:hover {
|
||||
background: rgb(153, 204, 0);
|
||||
}
|
||||
|
||||
.button-small {
|
||||
background: #444;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.button-small:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.button-label-big {
|
||||
color: #222;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.button-label-small {
|
||||
color: rgb(153, 204, 0);
|
||||
font-size: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(134, 179, 0);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dont-worry,
|
||||
#msg {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.icon-warning {
|
||||
color: #dec340;
|
||||
height: 4rem;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
font-family: Fira, FiraCode, monospace;
|
||||
background: #333;
|
||||
padding: 0.5rem 1rem;
|
||||
max-width: 40rem;
|
||||
border-radius: 10px;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary > * {
|
||||
display: inline;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
details {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
65
packages/backend/src/server/web/views/error.pug
Normal file
65
packages/backend/src/server/web/views/error.pug
Normal file
@ -0,0 +1,65 @@
|
||||
doctype html
|
||||
|
||||
//
|
||||
-
|
||||
_____ _ _
|
||||
| |_|___ ___| |_ ___ _ _
|
||||
| | | | |_ -|_ -| '_| -_| | |
|
||||
|_|_|_|_|___|___|_,_|___|_ |
|
||||
|___|
|
||||
Thank you for using Misskey!
|
||||
If you are reading this message... how about joining the development?
|
||||
https://github.com/misskey-dev/misskey
|
||||
|
||||
|
||||
html
|
||||
|
||||
head
|
||||
meta(charset='utf-8')
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1')
|
||||
meta(name='application-name' content='Misskey')
|
||||
meta(name='referrer' content='origin')
|
||||
|
||||
title
|
||||
block title
|
||||
= 'An error has occurred... | Misskey'
|
||||
|
||||
style
|
||||
include ../error.css
|
||||
|
||||
body
|
||||
svg.icon-warning(xmlns="http://www.w3.org/2000/svg", viewBox="0 0 24 24", stroke-width="2", stroke="currentColor", fill="none", stroke-linecap="round", stroke-linejoin="round")
|
||||
path(stroke="none", d="M0 0h24v24H0z", fill="none")
|
||||
path(d="M12 9v2m0 4v.01")
|
||||
path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75")
|
||||
|
||||
h1 An error has occurred!
|
||||
|
||||
button.button-big(onclick="location.reload();")
|
||||
span.button-label-big Refresh
|
||||
|
||||
p.dont-worry Don't worry, it's (probably) not your fault.
|
||||
|
||||
p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
|
||||
|
||||
div#errors
|
||||
code.
|
||||
ERROR CODE: #{code}
|
||||
ERROR ID: #{id}
|
||||
|
||||
p You may also try the following options:
|
||||
|
||||
p Update your os and browser.
|
||||
p Disable an adblocker.
|
||||
|
||||
a(href="/flush")
|
||||
button.button-small
|
||||
span.button-label-small Clear preferences and cache
|
||||
br
|
||||
a(href="/cli")
|
||||
button.button-small
|
||||
span.button-label-small Start the simple client
|
||||
br
|
||||
a(href="/bios")
|
||||
button.button-small
|
||||
span.button-label-small Start the repair tool
|
868
packages/backend/test/e2e/users.ts
Normal file
868
packages/backend/test/e2e/users.ts
Normal file
@ -0,0 +1,868 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { inspect } from 'node:util';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import {
|
||||
signup,
|
||||
post,
|
||||
page,
|
||||
role,
|
||||
startServer,
|
||||
api,
|
||||
successfulApiCall,
|
||||
failedApiCall,
|
||||
uploadFile,
|
||||
} from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
|
||||
describe('ユーザー', () => {
|
||||
// エンティティとしてのユーザーを主眼においたテストを記述する
|
||||
// (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする)
|
||||
|
||||
const stripUndefined = <T extends { [key: string]: any }, >(orig: T): Partial<T> => {
|
||||
return Object.entries({ ...orig })
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.reduce((obj: Partial<T>, [key, value]) => {
|
||||
obj[key as keyof T] = value;
|
||||
return obj;
|
||||
}, {});
|
||||
};
|
||||
|
||||
// FIXME: 足りないキーがたくさんある
|
||||
type UserLite = misskey.entities.UserLite & {
|
||||
badgeRoles: any[],
|
||||
};
|
||||
|
||||
type UserDetailedNotMe = UserLite &
|
||||
misskey.entities.UserDetailed & {
|
||||
roles: any[],
|
||||
};
|
||||
|
||||
type MeDetailed = UserDetailedNotMe &
|
||||
misskey.entities.MeDetailed & {
|
||||
showTimelineReplies: boolean,
|
||||
achievements: object[],
|
||||
loggedInDays: number,
|
||||
policies: object,
|
||||
};
|
||||
|
||||
type User = MeDetailed & { token: string };
|
||||
|
||||
const show = async (id: string, me = alice): Promise<MeDetailed | UserDetailedNotMe> => {
|
||||
return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any;
|
||||
};
|
||||
|
||||
const userLite = (user: User): Partial<UserLite> => {
|
||||
return stripUndefined({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
host: user.host,
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatarBlurhash: user.avatarBlurhash,
|
||||
isBot: user.isBot,
|
||||
isCat: user.isCat,
|
||||
instance: user.instance,
|
||||
emojis: user.emojis,
|
||||
onlineStatus: user.onlineStatus,
|
||||
badgeRoles: user.badgeRoles,
|
||||
|
||||
// BUG isAdmin/isModeratorはUserLiteではなくMeDetailedOnlyに含まれる。
|
||||
isAdmin: undefined,
|
||||
isModerator: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const userDetailedNotMe = (user: User): Partial<UserDetailedNotMe> => {
|
||||
return stripUndefined({
|
||||
...userLite(user),
|
||||
url: user.url,
|
||||
uri: user.uri,
|
||||
movedToUri: user.movedToUri,
|
||||
alsoKnownAs: user.alsoKnownAs,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
lastFetchedAt: user.lastFetchedAt,
|
||||
bannerUrl: user.bannerUrl,
|
||||
bannerBlurhash: user.bannerBlurhash,
|
||||
isLocked: user.isLocked,
|
||||
isSilenced: user.isSilenced,
|
||||
isSuspended: user.isSuspended,
|
||||
description: user.description,
|
||||
location: user.location,
|
||||
birthday: user.birthday,
|
||||
lang: user.lang,
|
||||
fields: user.fields,
|
||||
followersCount: user.followersCount,
|
||||
followingCount: user.followingCount,
|
||||
notesCount: user.notesCount,
|
||||
pinnedNoteIds: user.pinnedNoteIds,
|
||||
pinnedNotes: user.pinnedNotes,
|
||||
pinnedPageId: user.pinnedPageId,
|
||||
pinnedPage: user.pinnedPage,
|
||||
publicReactions: user.publicReactions,
|
||||
ffVisibility: user.ffVisibility,
|
||||
twoFactorEnabled: user.twoFactorEnabled,
|
||||
usePasswordLessLogin: user.usePasswordLessLogin,
|
||||
securityKeys: user.securityKeys,
|
||||
roles: user.roles,
|
||||
});
|
||||
};
|
||||
|
||||
const userDetailedNotMeWithRelations = (user: User): Partial<UserDetailedNotMe> => {
|
||||
return stripUndefined({
|
||||
...userDetailedNotMe(user),
|
||||
isFollowing: user.isFollowing ?? false,
|
||||
isFollowed: user.isFollowed ?? false,
|
||||
hasPendingFollowRequestFromYou: user.hasPendingFollowRequestFromYou ?? false,
|
||||
hasPendingFollowRequestToYou: user.hasPendingFollowRequestToYou ?? false,
|
||||
isBlocking: user.isBlocking ?? false,
|
||||
isBlocked: user.isBlocked ?? false,
|
||||
isMuted: user.isMuted ?? false,
|
||||
isRenoteMuted: user.isRenoteMuted ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const meDetailed = (user: User, security = false): Partial<MeDetailed> => {
|
||||
return stripUndefined({
|
||||
...userDetailedNotMe(user),
|
||||
avatarId: user.avatarId,
|
||||
bannerId: user.bannerId,
|
||||
isModerator: user.isModerator,
|
||||
isAdmin: user.isAdmin,
|
||||
injectFeaturedNote: user.injectFeaturedNote,
|
||||
receiveAnnouncementEmail: user.receiveAnnouncementEmail,
|
||||
alwaysMarkNsfw: user.alwaysMarkNsfw,
|
||||
autoSensitive: user.autoSensitive,
|
||||
carefulBot: user.carefulBot,
|
||||
autoAcceptFollowed: user.autoAcceptFollowed,
|
||||
noCrawle: user.noCrawle,
|
||||
isExplorable: user.isExplorable,
|
||||
isDeleted: user.isDeleted,
|
||||
hideOnlineStatus: user.hideOnlineStatus,
|
||||
hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
|
||||
hasUnreadMentions: user.hasUnreadMentions,
|
||||
hasUnreadAnnouncement: user.hasUnreadAnnouncement,
|
||||
hasUnreadAntenna: user.hasUnreadAntenna,
|
||||
hasUnreadChannel: user.hasUnreadChannel,
|
||||
hasUnreadNotification: user.hasUnreadNotification,
|
||||
hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest,
|
||||
mutedWords: user.mutedWords,
|
||||
mutedInstances: user.mutedInstances,
|
||||
mutingNotificationTypes: user.mutingNotificationTypes,
|
||||
emailNotificationTypes: user.emailNotificationTypes,
|
||||
showTimelineReplies: user.showTimelineReplies,
|
||||
achievements: user.achievements,
|
||||
loggedInDays: user.loggedInDays,
|
||||
policies: user.policies,
|
||||
...(security ? {
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
securityKeysList: user.securityKeysList,
|
||||
} : {}),
|
||||
});
|
||||
};
|
||||
|
||||
let app: INestApplicationContext;
|
||||
|
||||
let root: User;
|
||||
let alice: User;
|
||||
let aliceNote: misskey.entities.Note;
|
||||
let alicePage: misskey.entities.Page;
|
||||
let aliceList: misskey.entities.UserList;
|
||||
|
||||
let bob: User;
|
||||
let bobNote: misskey.entities.Note;
|
||||
|
||||
let carol: User;
|
||||
let dave: User;
|
||||
let ellen: User;
|
||||
let frank: User;
|
||||
|
||||
let usersReplying: User[];
|
||||
|
||||
let userNoNote: User;
|
||||
let userNotExplorable: User;
|
||||
let userLocking: User;
|
||||
let userAdmin: User;
|
||||
let roleAdmin: any;
|
||||
let userModerator: User;
|
||||
let roleModerator: any;
|
||||
let userRolePublic: User;
|
||||
let rolePublic: any;
|
||||
let userRoleBadge: User;
|
||||
let roleBadge: any;
|
||||
let userSilenced: User;
|
||||
let roleSilenced: any;
|
||||
let userSuspended: User;
|
||||
let userDeletedBySelf: User;
|
||||
let userDeletedByAdmin: User;
|
||||
let userFollowingAlice: User;
|
||||
let userFollowedByAlice: User;
|
||||
let userBlockingAlice: User;
|
||||
let userBlockedByAlice: User;
|
||||
let userMutingAlice: User;
|
||||
let userMutedByAlice: User;
|
||||
let userRnMutingAlice: User;
|
||||
let userRnMutedByAlice: User;
|
||||
let userFollowRequesting: User;
|
||||
let userFollowRequested: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await signup({ username: 'alice' });
|
||||
alice = root;
|
||||
aliceNote = await post(alice, { text: 'test' }) as any;
|
||||
alicePage = await page(alice);
|
||||
aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body;
|
||||
bob = await signup({ username: 'bob' });
|
||||
bobNote = await post(bob, { text: 'test' }) as any;
|
||||
carol = await signup({ username: 'carol' });
|
||||
dave = await signup({ username: 'dave' });
|
||||
ellen = await signup({ username: 'ellen' });
|
||||
frank = await signup({ username: 'frank' });
|
||||
|
||||
// @alice -> @replyingへのリプライ。Promise.allで一気に作るとtimeoutしてしまうのでreduceで一つ一つawaitする
|
||||
usersReplying = await [...Array(10)].map((_, i) => i).reduce(async (acc, i) => {
|
||||
const u = await signup({ username: `replying${i}` });
|
||||
for (let j = 0; j < 10 - i; j++) {
|
||||
const p = await post(u, { text: `test${j}` });
|
||||
await post(alice, { text: `@${u.username} test${j}`, replyId: p.id });
|
||||
}
|
||||
|
||||
return (await acc).concat(u);
|
||||
}, Promise.resolve([] as User[]));
|
||||
|
||||
userNoNote = await signup({ username: 'userNoNote' });
|
||||
userNotExplorable = await signup({ username: 'userNotExplorable' });
|
||||
await post(userNotExplorable, { text: 'test' });
|
||||
await api('i/update', { isExplorable: false }, userNotExplorable);
|
||||
userLocking = await signup({ username: 'userLocking' });
|
||||
await post(userLocking, { text: 'test' });
|
||||
await api('i/update', { isLocked: true }, userLocking);
|
||||
userAdmin = await signup({ username: 'userAdmin' });
|
||||
roleAdmin = await role(root, { isAdministrator: true, name: 'Admin Role' });
|
||||
await api('admin/roles/assign', { userId: userAdmin.id, roleId: roleAdmin.id }, root);
|
||||
userModerator = await signup({ username: 'userModerator' });
|
||||
roleModerator = await role(root, { isModerator: true, name: 'Moderator Role' });
|
||||
await api('admin/roles/assign', { userId: userModerator.id, roleId: roleModerator.id }, root);
|
||||
userRolePublic = await signup({ username: 'userRolePublic' });
|
||||
rolePublic = await role(root, { isPublic: true, name: 'Public Role' });
|
||||
await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root);
|
||||
userRoleBadge = await signup({ username: 'userRoleBadge' });
|
||||
roleBadge = await role(root, { asBadge: true, name: 'Badge Role' });
|
||||
await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root);
|
||||
userSilenced = await signup({ username: 'userSilenced' });
|
||||
await post(userSilenced, { text: 'test' });
|
||||
roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } });
|
||||
await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root);
|
||||
userSuspended = await signup({ username: 'userSuspended' });
|
||||
await post(userSuspended, { text: 'test' });
|
||||
await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended });
|
||||
await api('admin/suspend-user', { userId: userSuspended.id }, root);
|
||||
userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' });
|
||||
await post(userDeletedBySelf, { text: 'test' });
|
||||
await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf);
|
||||
userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' });
|
||||
await post(userDeletedByAdmin, { text: 'test' });
|
||||
await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root);
|
||||
userFollowingAlice = await signup({ username: 'userFollowingAlice' });
|
||||
await post(userFollowingAlice, { text: 'test' });
|
||||
await api('following/create', { userId: alice.id }, userFollowingAlice);
|
||||
userFollowedByAlice = await signup({ username: 'userFollowedByAlice' });
|
||||
await post(userFollowedByAlice, { text: 'test' });
|
||||
await api('following/create', { userId: userFollowedByAlice.id }, alice);
|
||||
userBlockingAlice = await signup({ username: 'userBlockingAlice' });
|
||||
await post(userBlockingAlice, { text: 'test' });
|
||||
await api('blocking/create', { userId: alice.id }, userBlockingAlice);
|
||||
userBlockedByAlice = await signup({ username: 'userBlockedByAlice' });
|
||||
await post(userBlockedByAlice, { text: 'test' });
|
||||
await api('blocking/create', { userId: userBlockedByAlice.id }, alice);
|
||||
userMutingAlice = await signup({ username: 'userMutingAlice' });
|
||||
await post(userMutingAlice, { text: 'test' });
|
||||
await api('mute/create', { userId: alice.id }, userMutingAlice);
|
||||
userMutedByAlice = await signup({ username: 'userMutedByAlice' });
|
||||
await post(userMutedByAlice, { text: 'test' });
|
||||
await api('mute/create', { userId: userMutedByAlice.id }, alice);
|
||||
userRnMutingAlice = await signup({ username: 'userRnMutingAlice' });
|
||||
await post(userRnMutingAlice, { text: 'test' });
|
||||
await api('renote-mute/create', { userId: alice.id }, userRnMutingAlice);
|
||||
userRnMutedByAlice = await signup({ username: 'userRnMutedByAlice' });
|
||||
await post(userRnMutedByAlice, { text: 'test' });
|
||||
await api('renote-mute/create', { userId: userRnMutedByAlice.id }, alice);
|
||||
userFollowRequesting = await signup({ username: 'userFollowRequesting' });
|
||||
await post(userFollowRequesting, { text: 'test' });
|
||||
userFollowRequested = userLocking;
|
||||
await api('following/create', { userId: userFollowRequested.id }, userFollowRequesting);
|
||||
}, 1000 * 60 * 10);
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
alice = {
|
||||
...alice,
|
||||
...await successfulApiCall({ endpoint: 'i', parameters: {}, user: alice }) as any,
|
||||
};
|
||||
aliceNote = await successfulApiCall({ endpoint: 'notes/show', parameters: { noteId: aliceNote.id }, user: alice });
|
||||
});
|
||||
|
||||
//#region サインアップ(signup)
|
||||
|
||||
test('が作れる。(作りたての状態で自分のユーザー情報が取れる)', async () => {
|
||||
// SignupApiService.ts
|
||||
const response = await successfulApiCall({
|
||||
endpoint: 'signup',
|
||||
parameters: { username: 'zoe', password: 'password' },
|
||||
user: undefined,
|
||||
}) as unknown as User; // BUG MeDetailedに足りないキーがある
|
||||
|
||||
// signupの時はtokenが含まれる特別なMeDetailedが返ってくる
|
||||
assert.match(response.token, /[a-zA-Z0-9]{16}/);
|
||||
|
||||
// UserLite
|
||||
assert.match(response.id, /[0-9a-z]{10}/);
|
||||
assert.strictEqual(response.name, null);
|
||||
assert.strictEqual(response.username, 'zoe');
|
||||
assert.strictEqual(response.host, null);
|
||||
assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
|
||||
assert.strictEqual(response.avatarBlurhash, null);
|
||||
assert.strictEqual(response.isBot, false);
|
||||
assert.strictEqual(response.isCat, false);
|
||||
assert.strictEqual(response.instance, undefined);
|
||||
assert.deepStrictEqual(response.emojis, {});
|
||||
assert.strictEqual(response.onlineStatus, 'unknown');
|
||||
assert.deepStrictEqual(response.badgeRoles, []);
|
||||
// UserDetailedNotMeOnly
|
||||
assert.strictEqual(response.url, null);
|
||||
assert.strictEqual(response.uri, null);
|
||||
assert.strictEqual(response.movedToUri, null);
|
||||
assert.strictEqual(response.alsoKnownAs, null);
|
||||
assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString());
|
||||
assert.strictEqual(response.updatedAt, null);
|
||||
assert.strictEqual(response.lastFetchedAt, null);
|
||||
assert.strictEqual(response.bannerUrl, null);
|
||||
assert.strictEqual(response.bannerBlurhash, null);
|
||||
assert.strictEqual(response.isLocked, false);
|
||||
assert.strictEqual(response.isSilenced, false);
|
||||
assert.strictEqual(response.isSuspended, false);
|
||||
assert.strictEqual(response.description, null);
|
||||
assert.strictEqual(response.location, null);
|
||||
assert.strictEqual(response.birthday, null);
|
||||
assert.strictEqual(response.lang, null);
|
||||
assert.deepStrictEqual(response.fields, []);
|
||||
assert.strictEqual(response.followersCount, 0);
|
||||
assert.strictEqual(response.followingCount, 0);
|
||||
assert.strictEqual(response.notesCount, 0);
|
||||
assert.deepStrictEqual(response.pinnedNoteIds, []);
|
||||
assert.deepStrictEqual(response.pinnedNotes, []);
|
||||
assert.strictEqual(response.pinnedPageId, null);
|
||||
assert.strictEqual(response.pinnedPage, null);
|
||||
assert.strictEqual(response.publicReactions, false);
|
||||
assert.strictEqual(response.ffVisibility, 'public');
|
||||
assert.strictEqual(response.twoFactorEnabled, false);
|
||||
assert.strictEqual(response.usePasswordLessLogin, false);
|
||||
assert.strictEqual(response.securityKeys, false);
|
||||
assert.deepStrictEqual(response.roles, []);
|
||||
|
||||
// MeDetailedOnly
|
||||
assert.strictEqual(response.avatarId, null);
|
||||
assert.strictEqual(response.bannerId, null);
|
||||
assert.strictEqual(response.isModerator, false);
|
||||
assert.strictEqual(response.isAdmin, false);
|
||||
assert.strictEqual(response.injectFeaturedNote, true);
|
||||
assert.strictEqual(response.receiveAnnouncementEmail, true);
|
||||
assert.strictEqual(response.alwaysMarkNsfw, false);
|
||||
assert.strictEqual(response.autoSensitive, false);
|
||||
assert.strictEqual(response.carefulBot, false);
|
||||
assert.strictEqual(response.autoAcceptFollowed, true);
|
||||
assert.strictEqual(response.noCrawle, false);
|
||||
assert.strictEqual(response.isExplorable, true);
|
||||
assert.strictEqual(response.isDeleted, false);
|
||||
assert.strictEqual(response.hideOnlineStatus, false);
|
||||
assert.strictEqual(response.hasUnreadSpecifiedNotes, false);
|
||||
assert.strictEqual(response.hasUnreadMentions, false);
|
||||
assert.strictEqual(response.hasUnreadAnnouncement, false);
|
||||
assert.strictEqual(response.hasUnreadAntenna, false);
|
||||
assert.strictEqual(response.hasUnreadChannel, false);
|
||||
assert.strictEqual(response.hasUnreadNotification, false);
|
||||
assert.strictEqual(response.hasPendingReceivedFollowRequest, false);
|
||||
assert.deepStrictEqual(response.mutedWords, []);
|
||||
assert.deepStrictEqual(response.mutedInstances, []);
|
||||
assert.deepStrictEqual(response.mutingNotificationTypes, []);
|
||||
assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']);
|
||||
assert.strictEqual(response.showTimelineReplies, false);
|
||||
assert.deepStrictEqual(response.achievements, []);
|
||||
assert.deepStrictEqual(response.loggedInDays, 0);
|
||||
assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
|
||||
assert.notStrictEqual(response.email, undefined);
|
||||
assert.strictEqual(response.emailVerified, false);
|
||||
assert.deepStrictEqual(response.securityKeysList, []);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region 自分の情報(i)
|
||||
|
||||
test('を読み取ることができる。(自分)', async () => {
|
||||
const response = await successfulApiCall({
|
||||
endpoint: 'i',
|
||||
parameters: {},
|
||||
user: userNoNote,
|
||||
});
|
||||
const expected = meDetailed(userNoNote, true);
|
||||
expected.loggedInDays = 1; // iはloggedInDaysを更新する
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region 自分の情報の更新(i/update)
|
||||
|
||||
test.each([
|
||||
{ parameters: (): object => ({ name: null }) },
|
||||
{ parameters: (): object => ({ name: 'x'.repeat(50) }) },
|
||||
{ parameters: (): object => ({ name: 'x' }) },
|
||||
{ parameters: (): object => ({ name: 'My name' }) },
|
||||
{ parameters: (): object => ({ description: null }) },
|
||||
{ parameters: (): object => ({ description: 'x'.repeat(1500) }) },
|
||||
{ parameters: (): object => ({ description: 'x' }) },
|
||||
{ parameters: (): object => ({ description: 'My description' }) },
|
||||
{ parameters: (): object => ({ location: null }) },
|
||||
{ parameters: (): object => ({ location: 'x'.repeat(50) }) },
|
||||
{ parameters: (): object => ({ location: 'x' }) },
|
||||
{ parameters: (): object => ({ location: 'My location' }) },
|
||||
{ parameters: (): object => ({ birthday: '0000-00-00' }) },
|
||||
{ parameters: (): object => ({ birthday: '9999-99-99' }) },
|
||||
{ parameters: (): object => ({ lang: 'en-US' }) },
|
||||
{ parameters: (): object => ({ fields: [] }) },
|
||||
{ parameters: (): object => ({ fields: [{ name: 'x', value: 'x' }] }) },
|
||||
{ parameters: (): object => ({ fields: [{ name: 'x'.repeat(3000), value: 'x'.repeat(3000) }] }) }, // BUG? fieldには制限がない
|
||||
{ parameters: (): object => ({ fields: Array(16).fill({ name: 'x', value: 'y' }) }) },
|
||||
{ parameters: (): object => ({ isLocked: true }) },
|
||||
{ parameters: (): object => ({ isLocked: false }) },
|
||||
{ parameters: (): object => ({ isExplorable: false }) },
|
||||
{ parameters: (): object => ({ isExplorable: true }) },
|
||||
{ parameters: (): object => ({ hideOnlineStatus: true }) },
|
||||
{ parameters: (): object => ({ hideOnlineStatus: false }) },
|
||||
{ parameters: (): object => ({ publicReactions: false }) },
|
||||
{ parameters: (): object => ({ publicReactions: true }) },
|
||||
{ parameters: (): object => ({ autoAcceptFollowed: true }) },
|
||||
{ parameters: (): object => ({ autoAcceptFollowed: false }) },
|
||||
{ parameters: (): object => ({ noCrawle: true }) },
|
||||
{ parameters: (): object => ({ noCrawle: false }) },
|
||||
{ parameters: (): object => ({ isBot: true }) },
|
||||
{ parameters: (): object => ({ isBot: false }) },
|
||||
{ parameters: (): object => ({ isCat: true }) },
|
||||
{ parameters: (): object => ({ isCat: false }) },
|
||||
{ parameters: (): object => ({ showTimelineReplies: true }) },
|
||||
{ parameters: (): object => ({ showTimelineReplies: false }) },
|
||||
{ parameters: (): object => ({ injectFeaturedNote: true }) },
|
||||
{ parameters: (): object => ({ injectFeaturedNote: false }) },
|
||||
{ parameters: (): object => ({ receiveAnnouncementEmail: true }) },
|
||||
{ parameters: (): object => ({ receiveAnnouncementEmail: false }) },
|
||||
{ parameters: (): object => ({ alwaysMarkNsfw: true }) },
|
||||
{ parameters: (): object => ({ alwaysMarkNsfw: false }) },
|
||||
{ parameters: (): object => ({ autoSensitive: true }) },
|
||||
{ parameters: (): object => ({ autoSensitive: false }) },
|
||||
{ parameters: (): object => ({ ffVisibility: 'private' }) },
|
||||
{ parameters: (): object => ({ ffVisibility: 'followers' }) },
|
||||
{ parameters: (): object => ({ ffVisibility: 'public' }) },
|
||||
{ parameters: (): object => ({ mutedWords: Array(19).fill(['xxxxx']) }) },
|
||||
{ parameters: (): object => ({ mutedWords: [['x'.repeat(194)]] }) },
|
||||
{ parameters: (): object => ({ mutedWords: [] }) },
|
||||
{ parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) },
|
||||
{ parameters: (): object => ({ mutedInstances: [] }) },
|
||||
{ parameters: (): object => ({ mutingNotificationTypes: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) },
|
||||
{ parameters: (): object => ({ mutingNotificationTypes: [] }) },
|
||||
{ parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) },
|
||||
{ parameters: (): object => ({ emailNotificationTypes: [] }) },
|
||||
] as const)('を書き換えることができる($#)', async ({ parameters }) => {
|
||||
const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters(), user: alice });
|
||||
const expected = { ...meDetailed(alice, true), ...parameters() };
|
||||
assert.deepStrictEqual(response, expected, inspect(parameters()));
|
||||
});
|
||||
|
||||
test('を書き換えることができる(Avatar)', async () => {
|
||||
const aliceFile = (await uploadFile(alice)).body;
|
||||
const parameters = { avatarId: aliceFile.id };
|
||||
const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice });
|
||||
assert.match(response.avatarUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
|
||||
assert.match(response.avatarBlurhash ?? '.', /[ -~]{54}/);
|
||||
const expected = {
|
||||
...meDetailed(alice, true),
|
||||
avatarId: aliceFile.id,
|
||||
avatarBlurhash: response.avatarBlurhash,
|
||||
avatarUrl: response.avatarUrl,
|
||||
};
|
||||
assert.deepStrictEqual(response, expected, inspect(parameters));
|
||||
|
||||
if (1) return; // BUG 521eb95 以降アバターのリセットができない。
|
||||
const parameters2 = { avatarId: null };
|
||||
const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice });
|
||||
const expected2 = {
|
||||
...meDetailed(alice, true),
|
||||
avatarId: null,
|
||||
avatarBlurhash: null,
|
||||
avatarUrl: alice.avatarUrl, // 解除した場合、identiconになる
|
||||
};
|
||||
assert.deepStrictEqual(response2, expected2, inspect(parameters));
|
||||
});
|
||||
|
||||
test('を書き換えることができる(Banner)', async () => {
|
||||
const aliceFile = (await uploadFile(alice)).body;
|
||||
const parameters = { bannerId: aliceFile.id };
|
||||
const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice });
|
||||
assert.match(response.bannerUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
|
||||
assert.match(response.bannerBlurhash ?? '.', /[ -~]{54}/);
|
||||
const expected = {
|
||||
...meDetailed(alice, true),
|
||||
bannerId: aliceFile.id,
|
||||
bannerBlurhash: response.bannerBlurhash,
|
||||
bannerUrl: response.bannerUrl,
|
||||
};
|
||||
assert.deepStrictEqual(response, expected, inspect(parameters));
|
||||
|
||||
if (1) return; // BUG 521eb95 以降バナーのリセットができない。
|
||||
const parameters2 = { bannerId: null };
|
||||
const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice });
|
||||
const expected2 = {
|
||||
...meDetailed(alice, true),
|
||||
bannerId: null,
|
||||
bannerBlurhash: null,
|
||||
bannerUrl: null,
|
||||
};
|
||||
assert.deepStrictEqual(response2, expected2, inspect(parameters));
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region 自分の情報の更新(i/pin, i/unpin)
|
||||
|
||||
test('を書き換えることができる(ピン止めノート)', async () => {
|
||||
const parameters = { noteId: aliceNote.id };
|
||||
const response = await successfulApiCall({ endpoint: 'i/pin', parameters, user: alice });
|
||||
const expected = { ...meDetailed(alice, false), pinnedNoteIds: [aliceNote.id], pinnedNotes: [aliceNote] };
|
||||
assert.deepStrictEqual(response, expected);
|
||||
|
||||
const response2 = await successfulApiCall({ endpoint: 'i/unpin', parameters, user: alice });
|
||||
const expected2 = meDetailed(alice, false);
|
||||
assert.deepStrictEqual(response2, expected2);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region ユーザー(users)
|
||||
|
||||
test.each([
|
||||
{ label: 'ID昇順', parameters: { limit: 5 }, selector: (u: UserLite): string => u.id },
|
||||
{ label: 'フォロワー昇順', parameters: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
|
||||
{ label: 'フォロワー降順', parameters: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
|
||||
{ label: '登録日時昇順', parameters: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
|
||||
{ label: '登録日時降順', parameters: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
|
||||
{ label: '投稿日時昇順', parameters: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
|
||||
{ label: '投稿日時降順', parameters: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
|
||||
] as const)('をリスト形式で取得することができる($label)', async ({ parameters, selector }) => {
|
||||
const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice });
|
||||
|
||||
// 結果の並びを事前にアサートするのは困難なので返ってきたidに対応するユーザーが返っており、ソート順が正しいことだけを検証する
|
||||
const users = await Promise.all(response.map(u => show(u.id)));
|
||||
const expected = users.sort((x, y) => {
|
||||
const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0;
|
||||
return index * (parameters.sort?.startsWith('+') ? -1 : 1);
|
||||
});
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test.each([
|
||||
{ label: '「見つけやすくする」がOFFのユーザーが含まれない', user: (): User => userNotExplorable, excluded: true },
|
||||
{ label: 'ミュートユーザーが含まれない', user: (): User => userMutedByAlice, excluded: true },
|
||||
{ label: 'ブロックされているユーザーが含まれない', user: (): User => userBlockedByAlice, excluded: true },
|
||||
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice, excluded: true },
|
||||
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
|
||||
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
|
||||
{ label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended },
|
||||
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
|
||||
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
|
||||
] as const)('をリスト形式で取得することができ、結果に$label', async ({ user, excluded }) => {
|
||||
const parameters = { limit: 100 };
|
||||
const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice });
|
||||
const expected = (excluded ?? false) ? [] : [await show(user().id)];
|
||||
assert.deepStrictEqual(response.filter((u) => u.id === user().id), expected);
|
||||
});
|
||||
test.todo('をリスト形式で取得することができる(リモート, hostname指定)');
|
||||
test.todo('をリスト形式で取得することができる(pagenation)');
|
||||
|
||||
//#endregion
|
||||
//#region ユーザー情報(users/show)
|
||||
|
||||
test.each([
|
||||
{ label: 'ID指定で自分自身を', parameters: (): object => ({ userId: alice.id }), user: (): User => alice, type: meDetailed },
|
||||
{ label: 'ID指定で他人を', parameters: (): object => ({ userId: alice.id }), user: (): User => bob, type: userDetailedNotMeWithRelations },
|
||||
{ label: 'ID指定かつ未認証', parameters: (): object => ({ userId: alice.id }), user: undefined, type: userDetailedNotMe },
|
||||
{ label: '@指定で自分自身を', parameters: (): object => ({ username: alice.username }), user: (): User => alice, type: meDetailed },
|
||||
{ label: '@指定で他人を', parameters: (): object => ({ username: alice.username }), user: (): User => bob, type: userDetailedNotMeWithRelations },
|
||||
{ label: '@指定かつ未認証', parameters: (): object => ({ username: alice.username }), user: undefined, type: userDetailedNotMe },
|
||||
] as const)('を取得することができる($label)', async ({ parameters, user, type }) => {
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters: parameters(), user: user?.() });
|
||||
const expected = type(alice);
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test.each([
|
||||
{ label: 'Administratorになっている', user: (): User => userAdmin, me: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin },
|
||||
{ label: '自分以外から見たときはAdministratorか判定できない', user: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin, expected: (): undefined => undefined },
|
||||
{ label: 'Moderatorになっている', user: (): User => userModerator, me: (): User => userModerator, selector: (user: User): unknown => user.isModerator },
|
||||
{ label: '自分以外から見たときはModeratorか判定できない', user: (): User => userModerator, selector: (user: User): unknown => user.isModerator, expected: (): undefined => undefined },
|
||||
{ label: 'サイレンスになっている', user: (): User => userSilenced, selector: (user: User): unknown => user.isSilenced },
|
||||
{ label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended },
|
||||
{ label: '削除済みになっている', user: (): User => userDeletedBySelf, me: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted },
|
||||
{ label: '自分以外から見たときは削除済みか判定できない', user: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined },
|
||||
{ label: '削除済み(byAdmin)になっている', user: (): User => userDeletedByAdmin, me: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted },
|
||||
{ label: '自分以外から見たときは削除済み(byAdmin)か判定できない', user: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined },
|
||||
{ label: 'フォロー中になっている', user: (): User => userFollowedByAlice, selector: (user: User): unknown => user.isFollowing },
|
||||
{ label: 'フォローされている', user: (): User => userFollowingAlice, selector: (user: User): unknown => user.isFollowed },
|
||||
{ label: 'ブロック中になっている', user: (): User => userBlockedByAlice, selector: (user: User): unknown => user.isBlocking },
|
||||
{ label: 'ブロックされている', user: (): User => userBlockingAlice, selector: (user: User): unknown => user.isBlocked },
|
||||
{ label: 'ミュート中になっている', user: (): User => userMutedByAlice, selector: (user: User): unknown => user.isMuted },
|
||||
{ label: 'リノートミュート中になっている', user: (): User => userRnMutedByAlice, selector: (user: User): unknown => user.isRenoteMuted },
|
||||
{ label: 'フォローリクエスト中になっている', user: (): User => userFollowRequested, me: (): User => userFollowRequesting, selector: (user: User): unknown => user.hasPendingFollowRequestFromYou },
|
||||
{ label: 'フォローリクエストされている', user: (): User => userFollowRequesting, me: (): User => userFollowRequested, selector: (user: User): unknown => user.hasPendingFollowRequestToYou },
|
||||
] as const)('を取得することができ、$labelこと', async ({ user, me, selector, expected }) => {
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: user().id }, user: me?.() ?? alice });
|
||||
assert.strictEqual(selector(response), (expected ?? ((): true => true))());
|
||||
});
|
||||
test('を取得することができ、Publicなロールがセットされていること', async () => {
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRolePublic.id }, user: alice });
|
||||
assert.deepStrictEqual(response.badgeRoles, []);
|
||||
assert.deepStrictEqual(response.roles, [{
|
||||
id: rolePublic.id,
|
||||
name: rolePublic.name,
|
||||
color: rolePublic.color,
|
||||
iconUrl: rolePublic.iconUrl,
|
||||
description: rolePublic.description,
|
||||
isModerator: rolePublic.isModerator,
|
||||
isAdministrator: rolePublic.isAdministrator,
|
||||
displayOrder: rolePublic.displayOrder,
|
||||
}]);
|
||||
});
|
||||
test('を取得することができ、バッヂロールがセットされていること', async () => {
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice });
|
||||
assert.deepStrictEqual(response.badgeRoles, [{
|
||||
name: roleBadge.name,
|
||||
iconUrl: roleBadge.iconUrl,
|
||||
displayOrder: roleBadge.displayOrder,
|
||||
}]);
|
||||
assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない
|
||||
});
|
||||
test('をID指定のリスト形式で取得することができる(空)', async () => {
|
||||
const parameters = { userIds: [] };
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });
|
||||
const expected: [] = [];
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test('をID指定のリスト形式で取得することができる', async() => {
|
||||
const parameters = { userIds: [bob.id, alice.id, carol.id] };
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });
|
||||
const expected = [
|
||||
await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }),
|
||||
await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }),
|
||||
await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }),
|
||||
];
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test.each([
|
||||
{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
|
||||
{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
|
||||
{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
|
||||
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
|
||||
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
|
||||
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
|
||||
{ label: 'サスペンドユーザーが(モデレーターが見るときは)含まれる', user: (): User => userSuspended, me: (): User => root },
|
||||
// BUG サスペンドユーザーを一般ユーザーから見るとrootユーザーが返ってくる
|
||||
//{ label: 'サスペンドユーザーが(一般ユーザーが見るときは)含まれない', user: (): User => userSuspended, me: (): User => bob, excluded: true },
|
||||
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
|
||||
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
|
||||
] as const)('をID指定のリスト形式で取得することができ、結果に$label', async ({ user, me, excluded }) => {
|
||||
const parameters = { userIds: [user().id] };
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: me?.() ?? alice });
|
||||
const expected = (excluded ?? false) ? [] : [await show(user().id, me?.() ?? alice)];
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test.todo('をID指定のリスト形式で取得することができる(リモート)');
|
||||
|
||||
//#endregion
|
||||
//#region 検索(users/search)
|
||||
|
||||
test('を検索することができる', async () => {
|
||||
const parameters = { query: 'carol', limit: 10 };
|
||||
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
|
||||
const expected = [await show(carol.id)];
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test('を検索することができる(UserLite)', async () => {
|
||||
const parameters = { query: 'carol', detail: false, limit: 10 };
|
||||
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
|
||||
const expected = [userLite(await show(carol.id))];
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test.each([
|
||||
{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
|
||||
{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
|
||||
{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
|
||||
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
|
||||
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
|
||||
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
|
||||
{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
|
||||
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
|
||||
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
|
||||
] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => {
|
||||
const parameters = { query: user().username, limit: 1 };
|
||||
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
|
||||
const expected = (excluded ?? false) ? [] : [await show(user().id)];
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test.todo('を検索することができる(リモート)');
|
||||
test.todo('を検索することができる(pagenation)');
|
||||
|
||||
//#endregion
|
||||
//#region ID指定検索(users/search-by-username-and-host)
|
||||
|
||||
test.each([
|
||||
{ label: '自分', parameters: { username: 'alice' }, user: (): User[] => [alice] },
|
||||
{ label: '自分かつusernameが大文字', parameters: { username: 'ALICE' }, user: (): User[] => [alice] },
|
||||
{ label: 'ローカルのフォロイーでノートなし', parameters: { username: 'userFollowedByAlice' }, user: (): User[] => [userFollowedByAlice] },
|
||||
{ label: 'ローカルでノートなしは検索に載らない', parameters: { username: 'userNoNote' }, user: (): User[] => [] },
|
||||
{ label: 'ローカルの他人1', parameters: { username: 'bob' }, user: (): User[] => [bob] },
|
||||
{ label: 'ローカルの他人2', parameters: { username: 'bob', host: null }, user: (): User[] => [bob] },
|
||||
{ label: 'ローカルの他人3', parameters: { username: 'bob', host: '.' }, user: (): User[] => [bob] },
|
||||
{ label: 'ローカル', parameters: { host: null, limit: 1 }, user: (): User[] => [userFollowedByAlice] },
|
||||
{ label: 'ローカル', parameters: { host: '.', limit: 1 }, user: (): User[] => [userFollowedByAlice] },
|
||||
])('をID&ホスト指定で検索できる($label)', async ({ parameters, user }) => {
|
||||
const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice });
|
||||
const expected = await Promise.all(user().map(u => show(u.id)));
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test.each([
|
||||
{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
|
||||
{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
|
||||
{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
|
||||
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
|
||||
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
|
||||
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
|
||||
{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
|
||||
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
|
||||
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
|
||||
] as const)('をID&ホスト指定で検索でき、結果に$label', async ({ user, excluded }) => {
|
||||
const parameters = { username: user().username };
|
||||
const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice });
|
||||
const expected = (excluded ?? false) ? [] : [await show(user().id)];
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test.todo('をID&ホスト指定で検索できる(リモート)');
|
||||
|
||||
//#endregion
|
||||
//#region ID指定検索(users/get-frequently-replied-users)
|
||||
|
||||
test('がよくリプライをするユーザーのリストを取得できる', async () => {
|
||||
const parameters = { userId: alice.id, limit: 5 };
|
||||
const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
|
||||
const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({
|
||||
user: await show(s.id),
|
||||
weight: (usersReplying.length - i) / usersReplying.length,
|
||||
})));
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test.each([
|
||||
{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
|
||||
{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
|
||||
{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
|
||||
{ label: 'ブロックしてきているユーザーが含まれない', user: (): User => userBlockingAlice, excluded: true },
|
||||
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
|
||||
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
|
||||
{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended },
|
||||
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
|
||||
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
|
||||
] as const)('がよくリプライをするユーザーのリストを取得でき、結果に$label', async ({ user, excluded }) => {
|
||||
const replyTo = (await successfulApiCall({ endpoint: 'users/notes', parameters: { userId: user().id }, user: undefined }))[0];
|
||||
await post(alice, { text: `@${user().username} test`, replyId: replyTo.id });
|
||||
const parameters = { userId: alice.id, limit: 100 };
|
||||
const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
|
||||
const expected = (excluded ?? false) ? [] : [await show(user().id)];
|
||||
assert.deepStrictEqual(response.map(s => s.user).filter((u) => u.id === user().id), expected);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region ハッシュタグ(hashtags/users)
|
||||
|
||||
test.each([
|
||||
{ label: 'フォロワー昇順', sort: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
|
||||
{ label: 'フォロワー降順', sort: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) },
|
||||
{ label: '登録日時昇順', sort: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
|
||||
{ label: '登録日時降順', sort: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt },
|
||||
{ label: '投稿日時昇順', sort: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
|
||||
{ label: '投稿日時降順', sort: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) },
|
||||
] as const)('をハッシュタグ指定で取得することができる($label)', async ({ sort, selector }) => {
|
||||
const hashtag = 'test_hashtag';
|
||||
await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: alice });
|
||||
const parameters = { tag: hashtag, limit: 5, ...sort };
|
||||
const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice });
|
||||
const users = await Promise.all(response.map(u => show(u.id)));
|
||||
const expected = users.sort((x, y) => {
|
||||
const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0;
|
||||
return index * (parameters.sort.startsWith('+') ? -1 : 1);
|
||||
});
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test.each([
|
||||
{ label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable },
|
||||
{ label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice },
|
||||
{ label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice },
|
||||
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
|
||||
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
|
||||
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
|
||||
{ label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended },
|
||||
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
|
||||
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
|
||||
] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user }) => {
|
||||
const hashtag = `user_test${user().username}`;
|
||||
if (user() !== userSuspended) {
|
||||
// サスペンドユーザーはupdateできない。
|
||||
await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: user() });
|
||||
}
|
||||
const parameters = { tag: hashtag, limit: 100, sort: '-follower' } as const;
|
||||
const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice });
|
||||
const expected = [await show(user().id)];
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
test.todo('をハッシュタグ指定で取得することができる(リモート)');
|
||||
|
||||
//#endregion
|
||||
//#region オススメユーザー(users/recommendation)
|
||||
|
||||
// BUG users/recommendationは壊れている? > QueryFailedError: missing FROM-clause entry for table "note"
|
||||
test.skip('のオススメを取得することができる', async () => {
|
||||
const parameters = {};
|
||||
const response = await successfulApiCall({ endpoint: 'users/recommendation', parameters, user: alice });
|
||||
const expected = await Promise.all(response.map(u => show(u.id)));
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region ピン止めユーザー(pinned-users)
|
||||
|
||||
test('のピン止めユーザーを取得することができる', async () => {
|
||||
await successfulApiCall({ endpoint: 'admin/update-meta', parameters: { pinnedUsers: [bob.username, `@${carol.username}`] }, user: root });
|
||||
const parameters = {} as const;
|
||||
const response = await successfulApiCall({ endpoint: 'pinned-users', parameters, user: alice });
|
||||
const expected = await Promise.all([bob, carol].map(u => show(u.id)));
|
||||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
test.todo('を管理人として確認することができる(admin/show-user)');
|
||||
test.todo('を管理人として確認することができる(admin/show-users)');
|
||||
test.todo('をサーバー向けに取得することができる(federation/users)');
|
||||
});
|
@ -6,6 +6,7 @@ import WebSocket from 'ws';
|
||||
import fetch, { Blob, File, RequestInit } from 'node-fetch';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
import { entities } from '../src/postgres.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
@ -31,12 +32,12 @@ export type ApiRequest = {
|
||||
};
|
||||
|
||||
export const successfulApiCall = async <T, >(request: ApiRequest, assertion: {
|
||||
status: number,
|
||||
} = { status: 200 }): Promise<T> => {
|
||||
status?: number,
|
||||
} = {}): Promise<T> => {
|
||||
const { endpoint, parameters, user } = request;
|
||||
const { status } = assertion;
|
||||
const res = await api(endpoint, parameters, user);
|
||||
assert.strictEqual(res.status, status, inspect(res.body));
|
||||
const status = assertion.status ?? (res.body == null ? 204 : 200);
|
||||
assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true }));
|
||||
return res.body;
|
||||
};
|
||||
|
||||
@ -188,6 +189,36 @@ export const channel = async (user: any, channel: any = {}): Promise<any> => {
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const role = async (user: any, role: any = {}, policies: any = {}): Promise<any> => {
|
||||
const res = await api('admin/roles/create', {
|
||||
asBadge: false,
|
||||
canEditMembersByModerator: false,
|
||||
color: null,
|
||||
condFormula: {
|
||||
id: 'ebef1684-672d-49b6-ad82-1b3ec3784f85',
|
||||
type: 'isRemote',
|
||||
},
|
||||
description: '',
|
||||
displayOrder: 0,
|
||||
iconUrl: null,
|
||||
isAdministrator: false,
|
||||
isModerator: false,
|
||||
isPublic: false,
|
||||
name: 'New Role',
|
||||
target: 'manual',
|
||||
policies: {
|
||||
...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, {
|
||||
priority: 0,
|
||||
useDefault: true,
|
||||
value: v,
|
||||
}]),
|
||||
...policies,
|
||||
},
|
||||
...role,
|
||||
}, user);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
interface UploadOptions {
|
||||
/** Optional, absolute path or relative from ./resources/ */
|
||||
path?: string | URL;
|
||||
|
@ -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>
|
||||
|
||||
|
@ -15,6 +15,7 @@ const props = defineProps<{
|
||||
list?: string;
|
||||
antenna?: string;
|
||||
channel?: string;
|
||||
role?: string;
|
||||
sound?: boolean;
|
||||
}>();
|
||||
|
||||
@ -121,6 +122,15 @@ if (props.src === 'antenna') {
|
||||
channelId: props.channel,
|
||||
});
|
||||
connection.on('note', prepend);
|
||||
} else if (props.src === 'role') {
|
||||
endpoint = 'roles/notes';
|
||||
query = {
|
||||
roleId: props.role,
|
||||
};
|
||||
connection = stream.useChannel('roleTimeline', {
|
||||
roleId: props.role,
|
||||
});
|
||||
connection.on('note', prepend);
|
||||
}
|
||||
|
||||
const pagination = {
|
||||
|
@ -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));
|
||||
},
|
||||
});
|
||||
|
@ -218,6 +218,7 @@ const patrons = [
|
||||
'Ebise Lutica',
|
||||
'巣黒るい@リスケモ男の娘VTuber!',
|
||||
'ふぇいぽむ',
|
||||
'依古田イコ',
|
||||
];
|
||||
|
||||
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));
|
||||
|
@ -65,7 +65,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
let key = $ref('');
|
||||
let tab = $ref('search');
|
||||
let tab = $ref('featured');
|
||||
let searchQuery = $ref('');
|
||||
let searchType = $ref('nameAndDescription');
|
||||
let channelPagination = $ref();
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<div class="lznhrdub">
|
||||
<div>
|
||||
<div v-if="tab === 'featured'">
|
||||
<XFeatured/>
|
||||
</div>
|
||||
|
@ -1,13 +1,16 @@
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader/></template>
|
||||
<template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template>
|
||||
|
||||
<MkSpacer :content-max="1200">
|
||||
<MkSpacer v-if="tab === 'users'" :content-max="1200">
|
||||
<div class="_gaps_s">
|
||||
<div v-if="role">{{ role.description }}</div>
|
||||
<MkUserList :pagination="users" :extractor="(item) => item.user"/>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<MkSpacer v-else-if="tab === 'timeline'" :content-max="700">
|
||||
<MkTimeline ref="timeline" src="role" :role="props.role"/>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
@ -16,11 +19,17 @@ import { computed, watch } from 'vue';
|
||||
import * as os from '@/os';
|
||||
import MkUserList from '@/components/MkUserList.vue';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { i18n } from '@/i18n';
|
||||
import MkTimeline from '@/components/MkTimeline.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
role: string;
|
||||
}>();
|
||||
initialTab?: string;
|
||||
}>(), {
|
||||
initialTab: 'users',
|
||||
});
|
||||
|
||||
let tab = $ref(props.initialTab);
|
||||
let role = $ref();
|
||||
|
||||
watch(() => props.role, () => {
|
||||
@ -39,6 +48,16 @@ const users = $computed(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const headerTabs = $computed(() => [{
|
||||
key: 'users',
|
||||
icon: 'ti ti-users',
|
||||
title: i18n.ts.users,
|
||||
}, {
|
||||
key: 'timeline',
|
||||
icon: 'ti ti-pencil',
|
||||
title: i18n.ts.timeline,
|
||||
}]);
|
||||
|
||||
definePageMetadata(computed(() => ({
|
||||
title: role?.name,
|
||||
icon: 'ti ti-badge',
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -343,15 +343,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[] = [];
|
||||
|
@ -152,6 +152,7 @@ const addColumn = async (ev) => {
|
||||
'channel',
|
||||
'mentions',
|
||||
'direct',
|
||||
'roleTimeline',
|
||||
];
|
||||
|
||||
const { canceled, result: column } = await os.select({
|
||||
|
@ -10,6 +10,7 @@
|
||||
<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
|
||||
<XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
|
||||
<XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
|
||||
<XRoleTimelineColumn v-else-if="column.type === 'roleTimeline'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@ -23,6 +24,7 @@ import XNotificationsColumn from './notifications-column.vue';
|
||||
import XWidgetsColumn from './widgets-column.vue';
|
||||
import XMentionsColumn from './mentions-column.vue';
|
||||
import XDirectColumn from './direct-column.vue';
|
||||
import XRoleTimelineColumn from './role-timeline-column.vue';
|
||||
import { Column } from './deck-store';
|
||||
|
||||
defineProps<{
|
||||
|
@ -22,6 +22,7 @@ export type Column = {
|
||||
antennaId?: string;
|
||||
listId?: string;
|
||||
channelId?: string;
|
||||
roleId?: string;
|
||||
includingTypes?: typeof notificationTypes[number][];
|
||||
tl?: 'home' | 'local' | 'social' | 'global';
|
||||
};
|
||||
|
67
packages/frontend/src/ui/deck/role-timeline-column.vue
Normal file
67
packages/frontend/src/ui/deck/role-timeline-column.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
|
||||
<template #header>
|
||||
<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name }}</span>
|
||||
</template>
|
||||
|
||||
<MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @after="() => emit('loaded')"/>
|
||||
</XColumn>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import XColumn from './column.vue';
|
||||
import { updateColumn, Column } from './deck-store';
|
||||
import MkTimeline from '@/components/MkTimeline.vue';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
column: Column;
|
||||
isStacked: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'loaded'): void;
|
||||
(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
|
||||
}>();
|
||||
|
||||
let timeline = $shallowRef<InstanceType<typeof MkTimeline>>();
|
||||
|
||||
onMounted(() => {
|
||||
if (props.column.roleId == null) {
|
||||
setRole();
|
||||
}
|
||||
});
|
||||
|
||||
async function setRole() {
|
||||
const roles = await os.api('roles/list');
|
||||
const { canceled, result: role } = await os.select({
|
||||
title: i18n.ts.role,
|
||||
items: roles.map(x => ({
|
||||
value: x, text: x.name,
|
||||
})),
|
||||
default: props.column.roleId,
|
||||
});
|
||||
if (canceled) return;
|
||||
updateColumn(props.column.id, {
|
||||
roleId: role.id,
|
||||
});
|
||||
}
|
||||
|
||||
const menu = [{
|
||||
icon: 'ti ti-pencil',
|
||||
text: i18n.ts.role,
|
||||
action: setRole,
|
||||
}];
|
||||
|
||||
/*
|
||||
function focus() {
|
||||
timeline.focus();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus,
|
||||
});
|
||||
*/
|
||||
</script>
|
@ -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
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,22 +1,20 @@
|
||||
/*
|
||||
* 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;
|
||||
@ -25,7 +23,7 @@ const iconUrl = (name: BadgeNames) => `/static-assets/tabler-badges/${name}.png`
|
||||
* 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) {
|
||||
@ -37,8 +35,7 @@ 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>;
|
||||
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');
|
||||
}
|
||||
|
||||
@ -226,10 +223,9 @@ 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(
|
||||
|
@ -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,17 +3,21 @@
|
||||
* 各種操作
|
||||
*/
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { SwMessage, SwMessageOrderType } from '@/types';
|
||||
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秒間隔に制限する
|
||||
@ -24,55 +28,52 @@ export function sendMarkAllAsRead(userId: string): Promise<null | undefined | vo
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
readBlockingStatus.set(userId, false);
|
||||
api('notifications/mark-all-as-read', userId)
|
||||
.then(resolve, resolve);
|
||||
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(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 { PushNotificationDataMap } from '@/types';
|
||||
import * as swos from '@/scripts/operations';
|
||||
import { acct as getAcct } from '@/filters/user';
|
||||
import { get } from 'idb-keyval';
|
||||
|
||||
globalThis.addEventListener('install', ev => {
|
||||
//ev.waitUntil(globalThis.skipWaiting());
|
||||
globalThis.addEventListener('install', () => {
|
||||
// ev.waitUntil(globalThis.skipWaiting());
|
||||
});
|
||||
|
||||
globalThis.addEventListener('activate', ev => {
|
||||
@ -43,7 +43,7 @@ globalThis.addEventListener('push', ev => {
|
||||
ev.waitUntil(globalThis.clients.matchAll({
|
||||
includeUncontrolled: true,
|
||||
type: 'window',
|
||||
}).then(async (clients: readonly WindowClient[]) => {
|
||||
}).then(async () => {
|
||||
const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.data?.json();
|
||||
|
||||
switch (data.type) {
|
||||
@ -66,7 +66,7 @@ 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);
|
||||
}
|
||||
@ -83,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);
|
||||
@ -120,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;
|
||||
}
|
||||
@ -160,7 +160,7 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv
|
||||
globalThis.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEventMap['notificationclose']) => {
|
||||
const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.notification.data;
|
||||
|
||||
ev.waitUntil((async () => {
|
||||
ev.waitUntil((async (): Promise<void> => {
|
||||
if (data.type === 'notification') {
|
||||
await swos.sendMarkAllAsRead(data.userId);
|
||||
}
|
||||
@ -169,7 +169,7 @@ globalThis.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEv
|
||||
});
|
||||
|
||||
globalThis.addEventListener('message', (ev: ServiceWorkerGlobalScopeEventMap['message']) => {
|
||||
ev.waitUntil((async () => {
|
||||
ev.waitUntil((async (): Promise<void> => {
|
||||
switch (ev.data) {
|
||||
case 'clear':
|
||||
// Cache Storage全削除
|
||||
|
@ -1,20 +1,20 @@
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type * as Misskey from 'misskey-js';
|
||||
|
||||
export type SwMessageOrderType = 'post' | 'push';
|
||||
|
||||
export type SwMessage = {
|
||||
type: 'order';
|
||||
order: SwMessageOrderType;
|
||||
loginId: string;
|
||||
loginId?: string;
|
||||
url: string;
|
||||
[x: string]: any;
|
||||
[x: string]: unknown;
|
||||
};
|
||||
|
||||
// Defined also @/core/PushNotificationService.ts#L12
|
||||
type PushNotificationDataSourceMap = {
|
||||
notification: Misskey.entities.Notification;
|
||||
unreadAntennaNote: {
|
||||
antenna: { id: string, name: string };
|
||||
antenna: { id: string; name: string };
|
||||
note: Misskey.entities.Note;
|
||||
};
|
||||
readAllNotifications: undefined;
|
||||
@ -31,8 +31,8 @@ export type PushNotificationDataMap = {
|
||||
[K in keyof PushNotificationDataSourceMap]: PushNotificationData<K>;
|
||||
};
|
||||
|
||||
export type BadgeNames =
|
||||
'null'
|
||||
export type BadgeNames =
|
||||
| 'null'
|
||||
| 'antenna'
|
||||
| 'arrow-back-up'
|
||||
| 'at'
|
||||
@ -44,5 +44,4 @@ export type BadgeNames =
|
||||
| 'quote'
|
||||
| 'repeat'
|
||||
| 'user-plus'
|
||||
| 'users'
|
||||
;
|
||||
| 'users';
|
||||
|
Loading…
Reference in New Issue
Block a user