Merge tag '13.11.3' into merge-upstream

This commit is contained in:
riku6460 2023-04-13 14:36:51 +09:00
commit 2911c6a468
No known key found for this signature in database
GPG key ID: 27414FA27DB94CF6
87 changed files with 2274 additions and 474 deletions

View file

@ -12,8 +12,31 @@
--> -->
## 13.11.3
### General
- 指定したロールを持つユーザーのノートのみが流れるロールタイムラインを追加
- Deckのカラムとしても追加可能
- カスタム絵文字関連の改善
* ートなどに含まれるemojispopulateEmojiの結果プロキシされた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 ## 13.11.2
### Note
- 13.11.0または13.11.1から13.11.2以降にアップデートする場合、Redisのカスタム絵文字のキャッシュを削除する必要があります(https://github.com/misskey-dev/misskey/issues/10502#issuecomment-1502790755 参照)
### General ### General
- チャンネルの検索用ページの追加 - チャンネルの検索用ページの追加

View file

@ -51,7 +51,7 @@ gulp.task('build:backend:script', () => {
}); });
gulp.task('build:backend:style', () => { 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({ .pipe(cssnano({
zindex: false zindex: false
})) }))

View file

@ -20,6 +20,7 @@ noNotes: "Keine Notizen gefunden"
noNotifications: "Keine Benachrichtigungen gefunden" noNotifications: "Keine Benachrichtigungen gefunden"
instance: "Instanz" instance: "Instanz"
settings: "Einstellungen" settings: "Einstellungen"
notificationSettings: "Benachrichtigungseinstellungen"
basicSettings: "Allgemeine Einstellungen" basicSettings: "Allgemeine Einstellungen"
otherSettings: "Weitere Einstellungen" otherSettings: "Weitere Einstellungen"
openInWindow: "In einem Fenster öffnen" openInWindow: "In einem Fenster öffnen"
@ -1407,6 +1408,8 @@ _channel:
following: "Gefolgt" following: "Gefolgt"
usersCount: "{n} Teilnehmer" usersCount: "{n} Teilnehmer"
notesCount: "{n} Notizen" notesCount: "{n} Notizen"
nameAndDescription: "Name und Beschreibung"
nameOnly: "Nur Name"
_menuDisplay: _menuDisplay:
sideFull: "Seitlich" sideFull: "Seitlich"
sideIcon: "Seitlich (Icons)" sideIcon: "Seitlich (Icons)"
@ -1887,6 +1890,7 @@ _deck:
channel: "Kanal" channel: "Kanal"
mentions: "Erwähnungen" mentions: "Erwähnungen"
direct: "Direktnachrichten" direct: "Direktnachrichten"
roleTimeline: "Rollenchronik"
_dialog: _dialog:
charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}" charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}"
charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}" charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}"

View file

@ -20,6 +20,7 @@ noNotes: "No notes"
noNotifications: "No notifications" noNotifications: "No notifications"
instance: "Instance" instance: "Instance"
settings: "Settings" settings: "Settings"
notificationSettings: "Notification Settings"
basicSettings: "Basic Settings" basicSettings: "Basic Settings"
otherSettings: "Other Settings" otherSettings: "Other Settings"
openInWindow: "Open in window" openInWindow: "Open in window"
@ -1407,6 +1408,8 @@ _channel:
following: "Followed" following: "Followed"
usersCount: "{n} Participants" usersCount: "{n} Participants"
notesCount: "{n} Notes" notesCount: "{n} Notes"
nameAndDescription: "Name and description"
nameOnly: "Name only"
_menuDisplay: _menuDisplay:
sideFull: "Side" sideFull: "Side"
sideIcon: "Side (Icons)" sideIcon: "Side (Icons)"
@ -1887,6 +1890,7 @@ _deck:
channel: "Channel" channel: "Channel"
mentions: "Mentions" mentions: "Mentions"
direct: "Direct notes" direct: "Direct notes"
roleTimeline: "Role Timeline"
_dialog: _dialog:
charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}." 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}." charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}."

View file

@ -20,6 +20,7 @@ noNotes: "Nessuna nota!"
noNotifications: "Nessuna notifica" noNotifications: "Nessuna notifica"
instance: "Istanza" instance: "Istanza"
settings: "Impostazioni" settings: "Impostazioni"
notificationSettings: "Preferenze di notifica"
basicSettings: "Impostazioni generali" basicSettings: "Impostazioni generali"
otherSettings: "Altre impostazioni" otherSettings: "Altre impostazioni"
openInWindow: "Apri in una finestra" openInWindow: "Apri in una finestra"
@ -786,7 +787,7 @@ gallery: "Galleria"
recentPosts: "Le più recenti" recentPosts: "Le più recenti"
popularPosts: "Le più visualizzate" popularPosts: "Le più visualizzate"
shareWithNote: "Condividere in nota" shareWithNote: "Condividere in nota"
ads: "Pubblicità" ads: "Banner"
expiration: "Scadenza" expiration: "Scadenza"
startingperiod: "Periodo di inizio" startingperiod: "Periodo di inizio"
memo: "Promemoria" memo: "Promemoria"
@ -991,6 +992,7 @@ largeNoteReactions: "Ingrandisci le reazioni"
noteIdOrUrl: "ID della Nota o URL" noteIdOrUrl: "ID della Nota o URL"
accountMigration: "Migrazione del profilo" accountMigration: "Migrazione del profilo"
accountMoved: "Questo profilo ha migrato altrove:" accountMoved: "Questo profilo ha migrato altrove:"
forceShowAds: "Mostra sempre i banner"
_accountMigration: _accountMigration:
moveTo: "Migrare questo profilo verso un un altro" moveTo: "Migrare questo profilo verso un un altro"
moveToLabel: "Profilo verso cui migrare" moveToLabel: "Profilo verso cui migrare"
@ -1406,6 +1408,8 @@ _channel:
following: "Seguiti" following: "Seguiti"
usersCount: "{n} partecipanti" usersCount: "{n} partecipanti"
notesCount: "{n} note" notesCount: "{n} note"
nameAndDescription: "Nome e descrizione"
nameOnly: "Solo il nome"
_menuDisplay: _menuDisplay:
sideFull: "Laterale" sideFull: "Laterale"
sideIcon: "Laterale (solo icone)" sideIcon: "Laterale (solo icone)"

View file

@ -1943,6 +1943,7 @@ _deck:
channel: "チャンネル" channel: "チャンネル"
mentions: "あなた宛て" mentions: "あなた宛て"
direct: "ダイレクト" direct: "ダイレクト"
roleTimeline: "ロールタイムライン"
_dialog: _dialog:
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"

View file

@ -20,6 +20,7 @@ noNotes: "ノートはあらへん"
noNotifications: "通知はあらへん" noNotifications: "通知はあらへん"
instance: "サーバー" instance: "サーバー"
settings: "設定" settings: "設定"
notificationSettings: "通知の設定"
basicSettings: "基本設定" basicSettings: "基本設定"
otherSettings: "ほかの設定" otherSettings: "ほかの設定"
openInWindow: "ウィンドウで開くで" openInWindow: "ウィンドウで開くで"
@ -1407,6 +1408,8 @@ _channel:
following: "フォロー中やで" following: "フォロー中やで"
usersCount: "{n}人が参加中やで" usersCount: "{n}人が参加中やで"
notesCount: "{n}こ投稿があるで" notesCount: "{n}こ投稿があるで"
nameAndDescription: "名前と説明"
nameOnly: "名前だけ"
_menuDisplay: _menuDisplay:
sideFull: "横" sideFull: "横"
sideIcon: "横(アイコン)" sideIcon: "横(アイコン)"
@ -1887,6 +1890,7 @@ _deck:
channel: "チャンネル" channel: "チャンネル"
mentions: "あんた宛て" mentions: "あんた宛て"
direct: "ダイレクト" direct: "ダイレクト"
roleTimeline: "ロールタイムライン"
_dialog: _dialog:
charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}" charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}"
charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}" charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}"

View file

@ -20,6 +20,7 @@ noNotes: "没有帖文"
noNotifications: "无通知" noNotifications: "无通知"
instance: "服务器" instance: "服务器"
settings: "设置" settings: "设置"
notificationSettings: "通知设置"
basicSettings: "基本设置" basicSettings: "基本设置"
otherSettings: "其他设置" otherSettings: "其他设置"
openInWindow: "在新窗口中打开" openInWindow: "在新窗口中打开"
@ -1407,6 +1408,8 @@ _channel:
following: "正在关注" following: "正在关注"
usersCount: "有{n}人参与" usersCount: "有{n}人参与"
notesCount: "有{n}个帖子" notesCount: "有{n}个帖子"
nameAndDescription: "名称与描述"
nameOnly: "仅名称"
_menuDisplay: _menuDisplay:
sideFull: "横向" sideFull: "横向"
sideIcon: "横向(图标)" sideIcon: "横向(图标)"
@ -1887,6 +1890,7 @@ _deck:
channel: "频道" channel: "频道"
mentions: "提及" mentions: "提及"
direct: "指定用户" direct: "指定用户"
roleTimeline: "角色时间线"
_dialog: _dialog:
charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}"
charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}"

View file

@ -20,6 +20,7 @@ noNotes: "無貼文。"
noNotifications: "沒有通知" noNotifications: "沒有通知"
instance: "實例" instance: "實例"
settings: "設定" settings: "設定"
notificationSettings: "通知選項"
basicSettings: "基本設定" basicSettings: "基本設定"
otherSettings: "其他設定" otherSettings: "其他設定"
openInWindow: "在新視窗開啟" openInWindow: "在新視窗開啟"
@ -506,6 +507,7 @@ objectStorageUseSSLDesc: "如果不使用https進行API連接請關閉"
objectStorageUseProxy: "使用網路代理" objectStorageUseProxy: "使用網路代理"
objectStorageUseProxyDesc: "如果不使用代理進行API連接請關閉" objectStorageUseProxyDesc: "如果不使用代理進行API連接請關閉"
objectStorageSetPublicRead: "上傳時設定為\"public-read\"" objectStorageSetPublicRead: "上傳時設定為\"public-read\""
s3ForcePathStyleDesc: "啟用 s3ForcePathStyle 會強制將儲存槽名稱指定為 URL 中路徑的一部分,而不是主機名。 使用自託管 Minio 之類的可能需要啟用。"
serverLogs: "伺服器日誌" serverLogs: "伺服器日誌"
deleteAll: "刪除所有記錄" deleteAll: "刪除所有記錄"
showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框" showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框"
@ -560,7 +562,7 @@ inboxUrl: "收件夾URL"
addedRelays: "已加入的中繼" addedRelays: "已加入的中繼"
serviceworkerInfo: "您需要啟用推送通知" serviceworkerInfo: "您需要啟用推送通知"
deletedNote: "已删除的貼文" deletedNote: "已删除的貼文"
invisibleNote: "隱藏的貼文" invisibleNote: "私密的貼文"
enableInfiniteScroll: "啟用自動滾動頁面模式" enableInfiniteScroll: "啟用自動滾動頁面模式"
visibility: "可見性" visibility: "可見性"
poll: "投票" poll: "投票"
@ -919,6 +921,7 @@ pushNotificationNotSupported: "瀏覽器或實例不支援推播通知"
sendPushNotificationReadMessage: "通知與訊息如果已讀的話,就將推播通知刪除" sendPushNotificationReadMessage: "通知與訊息如果已讀的話,就將推播通知刪除"
sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通知將立刻顯示。可能會增加設備的電池消耗。" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通知將立刻顯示。可能會增加設備的電池消耗。"
windowMaximize: "最大化" windowMaximize: "最大化"
windowMinimize: "最小化"
windowRestore: "復原" windowRestore: "復原"
caption: "標題" caption: "標題"
loggedInAsBot: "以機器人帳戶登入中" loggedInAsBot: "以機器人帳戶登入中"
@ -960,6 +963,9 @@ copyErrorInfo: "複製錯誤資訊"
joinThisServer: "在此伺服器上註冊" joinThisServer: "在此伺服器上註冊"
exploreOtherServers: "探索其他伺服器" exploreOtherServers: "探索其他伺服器"
letsLookAtTimeline: "看看時間軸" letsLookAtTimeline: "看看時間軸"
disableFederationConfirm: "要停止聯邦功能嗎?"
disableFederationConfirmWarn: "即使停止了聯邦功能,貼文也不會變成私密的。在大部分的情況下,沒有必要停止聯邦功能。"
disableFederationOk: "停止聯邦功能"
invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。" invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。"
emailNotSupported: "這個伺服器不支援寄送郵件" emailNotSupported: "這個伺服器不支援寄送郵件"
postToTheChannel: "發布到頻道" postToTheChannel: "發布到頻道"
@ -985,6 +991,7 @@ showClipButtonInNoteFooter: "將摘錄添加至貼文"
largeNoteReactions: "將貼文的反應放大顯示" largeNoteReactions: "將貼文的反應放大顯示"
noteIdOrUrl: "貼文ID或URL" noteIdOrUrl: "貼文ID或URL"
accountMigration: "遷移帳戶" accountMigration: "遷移帳戶"
accountMoved: "這個使用者已遷移至新的帳戶:"
forceShowAds: "總是顯示廣告" forceShowAds: "總是顯示廣告"
_accountMigration: _accountMigration:
moveTo: "將這個帳戶遷移至新的帳戶" moveTo: "將這個帳戶遷移至新的帳戶"
@ -1401,6 +1408,8 @@ _channel:
following: "關注中" following: "關注中"
usersCount: "有{n}人參與" usersCount: "有{n}人參與"
notesCount: "有{n}個貼文" notesCount: "有{n}個貼文"
nameAndDescription: "名稱與說明"
nameOnly: "僅名稱"
_menuDisplay: _menuDisplay:
sideFull: "側向" sideFull: "側向"
sideIcon: "側向(圖示)" sideIcon: "側向(圖示)"
@ -1881,6 +1890,7 @@ _deck:
channel: "頻道" channel: "頻道"
mentions: "提及" mentions: "提及"
direct: "指定使用者" direct: "指定使用者"
roleTimeline: "角色時間軸"
_dialog: _dialog:
charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}" charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}"
charactersBelow: "低於最少字數!現在 {current} / 限制 {max}" charactersBelow: "低於最少字數!現在 {current} / 限制 {max}"

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.11.2", "version": "13.11.3",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -91,14 +91,24 @@ export class AntennaService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise<void> { public async addNoteToAntennas(note: Note, noteUser: { id: User['id']; username: string; host: string | null; }): Promise<void> {
this.redisClient.xadd( const antennas = await this.getAntennas();
`antennaTimeline:${antenna.id}`, const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
'MAXLEN', '~', '200', const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
'*',
'note', note.id); const redisPipeline = this.redisClient.pipeline();
this.globalEventService.publishAntennaStream(antenna.id, 'note', note); 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: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている

View file

@ -13,6 +13,7 @@ import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { query } from '@/misc/prelude/url.js'; import { query } from '@/misc/prelude/url.js';
import type { Serialized } from '@/server/api/stream/types.js';
@Injectable() @Injectable()
export class CustomEmojiService { export class CustomEmojiService {
@ -44,7 +45,13 @@ export class CustomEmojiService {
memoryCacheLifetime: 1000 * 60 * 3, // 3m memoryCacheLifetime: 1000 * 60 * 3, // 3m
fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))), 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())), 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); const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull);
if (emoji == null) return null; if (emoji == null) return null;
return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
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;
} }
/** /**

View file

@ -14,11 +14,13 @@ import type {
MainStreamTypes, MainStreamTypes,
NoteStreamTypes, NoteStreamTypes,
UserListStreamTypes, UserListStreamTypes,
RoleTimelineStreamTypes,
} from '@/server/api/stream/types.js'; } from '@/server/api/stream/types.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { Role } from '@/models';
@Injectable() @Injectable()
export class GlobalEventService { export class GlobalEventService {
@ -81,6 +83,11 @@ export class GlobalEventService {
this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); 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 @bindThis
public publishNotesStream(note: Packed<'Note'>): void { public publishNotesStream(note: Packed<'Note'>): void {
this.publish('notesStream', null, note); this.publish('notesStream', null, note);

View file

@ -493,14 +493,7 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
}); });
// Antenna this.antennaService.addNoteToAntennas(note, user);
for (const antenna of (await this.antennaService.getAntennas())) {
this.antennaService.checkHitAntenna(antenna, note, user).then(hit => {
if (hit) {
this.antennaService.addNoteToAntenna(antenna, note, user);
}
});
}
if (data.reply) { if (data.reply) {
this.saveReply(data.reply, note); this.saveReply(data.reply, note);
@ -554,6 +547,8 @@ export class NoteCreateService implements OnApplicationShutdown {
this.globalEventService.publishNotesStream(noteObj); this.globalEventService.publishNotesStream(noteObj);
this.roleService.addNoteToRoleTimeline(noteObj);
this.webhookService.getActiveWebhooks().then(webhooks => { this.webhookService.getActiveWebhooks().then(webhooks => {
webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
for (const webhook of webhooks) { for (const webhook of webhooks) {

View file

@ -3,7 +3,7 @@ import Bull from 'bull';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { Provider } from '@nestjs/common'; 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) { function q<T>(config: Config, name: string, limitPerSec = -1) {
return new Bull<T>(name, { 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 EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
export type DeliverQueue = Bull.Queue<DeliverJobData>; export type DeliverQueue = Bull.Queue<DeliverJobData>;
export type InboxQueue = Bull.Queue<InboxJobData>; 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 ObjectStorageQueue = Bull.Queue<ObjectStorageJobData>;
export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>; export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>;
@ -75,6 +76,12 @@ const $db: Provider = {
inject: [DI.config], inject: [DI.config],
}; };
const $relationship: Provider = {
provide: 'queue:relationship',
useFactory: (config: Config) => q(config, 'relationship'),
inject: [DI.config],
};
const $objectStorage: Provider = { const $objectStorage: Provider = {
provide: 'queue:objectStorage', provide: 'queue:objectStorage',
useFactory: (config: Config) => q(config, 'objectStorage'), useFactory: (config: Config) => q(config, 'objectStorage'),
@ -96,6 +103,7 @@ const $webhookDeliver: Provider = {
$deliver, $deliver,
$inbox, $inbox,
$db, $db,
$relationship,
$objectStorage, $objectStorage,
$webhookDeliver, $webhookDeliver,
], ],
@ -105,6 +113,7 @@ const $webhookDeliver: Provider = {
$deliver, $deliver,
$inbox, $inbox,
$db, $db,
$relationship,
$objectStorage, $objectStorage,
$webhookDeliver, $webhookDeliver,
], ],

View file

@ -6,9 +6,10 @@ import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js';
import type { ThinUser } from '../queue/types.js'; import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js';
import type httpSignature from '@peertube/http-signature'; import type httpSignature from '@peertube/http-signature';
import Bull from 'bull';
@Injectable() @Injectable()
export class QueueService { export class QueueService {
@ -21,6 +22,7 @@ export class QueueService {
@Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:db') public dbQueue: DbQueue,
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
) {} ) {}
@ -56,7 +58,7 @@ export class QueueService {
activity: activity, activity: activity,
signature, signature,
}; };
return this.inboxQueue.add(data, { return this.inboxQueue.add(data, {
attempts: this.config.inboxJobMaxAttempts ?? 8, attempts: this.config.inboxJobMaxAttempts ?? 8,
timeout: 5 * 60 * 1000, // 5min timeout: 5 * 60 * 1000, // 5min
@ -71,7 +73,7 @@ export class QueueService {
@bindThis @bindThis
public createDeleteDriveFilesJob(user: ThinUser) { public createDeleteDriveFilesJob(user: ThinUser) {
return this.dbQueue.add('deleteDriveFiles', { return this.dbQueue.add('deleteDriveFiles', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -81,7 +83,7 @@ export class QueueService {
@bindThis @bindThis
public createExportCustomEmojisJob(user: ThinUser) { public createExportCustomEmojisJob(user: ThinUser) {
return this.dbQueue.add('exportCustomEmojis', { return this.dbQueue.add('exportCustomEmojis', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -91,7 +93,7 @@ export class QueueService {
@bindThis @bindThis
public createExportNotesJob(user: ThinUser) { public createExportNotesJob(user: ThinUser) {
return this.dbQueue.add('exportNotes', { return this.dbQueue.add('exportNotes', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -101,7 +103,7 @@ export class QueueService {
@bindThis @bindThis
public createExportFavoritesJob(user: ThinUser) { public createExportFavoritesJob(user: ThinUser) {
return this.dbQueue.add('exportFavorites', { return this.dbQueue.add('exportFavorites', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -111,7 +113,7 @@ export class QueueService {
@bindThis @bindThis
public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) { public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) {
return this.dbQueue.add('exportFollowing', { return this.dbQueue.add('exportFollowing', {
user: user, user: { id: user.id },
excludeMuting, excludeMuting,
excludeInactive, excludeInactive,
}, { }, {
@ -123,7 +125,7 @@ export class QueueService {
@bindThis @bindThis
public createExportMuteJob(user: ThinUser) { public createExportMuteJob(user: ThinUser) {
return this.dbQueue.add('exportMuting', { return this.dbQueue.add('exportMuting', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -133,7 +135,7 @@ export class QueueService {
@bindThis @bindThis
public createExportBlockingJob(user: ThinUser) { public createExportBlockingJob(user: ThinUser) {
return this.dbQueue.add('exportBlocking', { return this.dbQueue.add('exportBlocking', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -143,7 +145,7 @@ export class QueueService {
@bindThis @bindThis
public createExportUserListsJob(user: ThinUser) { public createExportUserListsJob(user: ThinUser) {
return this.dbQueue.add('exportUserLists', { return this.dbQueue.add('exportUserLists', {
user: user, user: { id: user.id },
}, { }, {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -153,7 +155,7 @@ export class QueueService {
@bindThis @bindThis
public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) { public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importFollowing', { return this.dbQueue.add('importFollowing', {
user: user, user: { id: user.id },
fileId: fileId, fileId: fileId,
}, { }, {
removeOnComplete: true, 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 @bindThis
public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) { public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importMuting', { return this.dbQueue.add('importMuting', {
user: user, user: { id: user.id },
fileId: fileId, fileId: fileId,
}, { }, {
removeOnComplete: true, removeOnComplete: true,
@ -175,7 +183,7 @@ export class QueueService {
@bindThis @bindThis
public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) { public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importBlocking', { return this.dbQueue.add('importBlocking', {
user: user, user: { id: user.id },
fileId: fileId, fileId: fileId,
}, { }, {
removeOnComplete: true, 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 @bindThis
public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) { public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importUserLists', { return this.dbQueue.add('importUserLists', {
user: user, user: { id: user.id },
fileId: fileId, fileId: fileId,
}, { }, {
removeOnComplete: true, removeOnComplete: true,
@ -197,7 +227,7 @@ export class QueueService {
@bindThis @bindThis
public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) { public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importCustomEmojis', { return this.dbQueue.add('importCustomEmojis', {
user: user, user: { id: user.id },
fileId: fileId, fileId: fileId,
}, { }, {
removeOnComplete: true, removeOnComplete: true,
@ -208,7 +238,7 @@ export class QueueService {
@bindThis @bindThis
public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) {
return this.dbQueue.add('deleteAccount', { return this.dbQueue.add('deleteAccount', {
user: user, user: { id: user.id },
soft: opts.soft, soft: opts.soft,
}, { }, {
removeOnComplete: true, 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 @bindThis
public createDeleteObjectStorageFileJob(key: string) { public createDeleteObjectStorageFileJob(key: string) {
return this.objectStorageQueue.add('deleteFile', { return this.objectStorageQueue.add('deleteFile', {
@ -246,7 +321,7 @@ export class QueueService {
createdAt: Date.now(), createdAt: Date.now(),
eventId: uuid(), eventId: uuid(),
}; };
return this.webhookDeliverQueue.add(data, { return this.webhookDeliverQueue.add(data, {
attempts: 4, attempts: 4,
timeout: 1 * 60 * 1000, // 1min timeout: 1 * 60 * 1000, // 1min
@ -264,7 +339,7 @@ export class QueueService {
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
}); });
this.deliverQueue.clean(0, 'delayed'); this.deliverQueue.clean(0, 'delayed');
this.inboxQueue.once('cleaned', (jobs, status) => { this.inboxQueue.once('cleaned', (jobs, status) => {
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
}); });

View file

@ -13,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { StreamMessages } from '@/server/api/stream/types.js'; import { StreamMessages } from '@/server/api/stream/types.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { Packed } from '@/misc/json-schema';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
export type RolePolicies = { export type RolePolicies = {
@ -64,6 +65,9 @@ export class RoleService implements OnApplicationShutdown {
public static NotAssignedError = class extends Error {}; public static NotAssignedError = class extends Error {};
constructor( constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForSub) @Inject(DI.redisForSub)
private redisForSub: Redis.Redis, private redisForSub: Redis.Redis,
@ -398,6 +402,25 @@ export class RoleService implements OnApplicationShutdown {
this.globalEventService.publishInternalEvent('userRoleUnassigned', existing); 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 @bindThis
public onApplicationShutdown(signal?: string | undefined) { public onApplicationShutdown(signal?: string | undefined) {
this.redisForSub.off('message', this.onMessage); this.redisForSub.off('message', this.onMessage);

View file

@ -24,7 +24,7 @@ export class UserBlockingService implements OnModuleInit {
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@Inject(DI.followRequestsRepository) @Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository, private followRequestsRepository: FollowRequestsRepository,
@ -54,12 +54,12 @@ export class UserBlockingService implements OnModuleInit {
} }
@bindThis @bindThis
public async block(blocker: User, blockee: User) { public async block(blocker: User, blockee: User, silent = false) {
await Promise.all([ await Promise.all([
this.cancelRequest(blocker, blockee), this.cancelRequest(blocker, blockee, silent),
this.cancelRequest(blockee, blocker), this.cancelRequest(blockee, blocker, silent),
this.userFollowingService.unfollow(blocker, blockee), this.userFollowingService.unfollow(blocker, blockee, silent),
this.userFollowingService.unfollow(blockee, blocker), this.userFollowingService.unfollow(blockee, blocker, silent),
this.removeFromList(blockee, blocker), this.removeFromList(blockee, blocker),
]); ]);
@ -89,7 +89,7 @@ export class UserBlockingService implements OnModuleInit {
} }
@bindThis @bindThis
private async cancelRequest(follower: User, followee: User) { private async cancelRequest(follower: User, followee: User, silent = false) {
const request = await this.followRequestsRepository.findOneBy({ const request = await this.followRequestsRepository.findOneBy({
followeeId: followee.id, followeeId: followee.id,
followerId: follower.id, followerId: follower.id,
@ -110,7 +110,7 @@ export class UserBlockingService implements OnModuleInit {
}).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); }).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, { this.userEntityService.pack(followee, follower, {
detail: true, detail: true,
}).then(async packed => { }).then(async packed => {

View file

@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js';
import { UserBlockingService } from '@/core/UserBlockingService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js';
import Logger from '../logger.js'; import Logger from '../logger.js';
const logger = new Logger('following/create'); const logger = new Logger('following/create');
@ -43,7 +44,10 @@ export class UserFollowingService implements OnModuleInit {
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -79,7 +83,7 @@ export class UserFollowingService implements OnModuleInit {
} }
@bindThis @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([ const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: _follower.id }), this.usersRepository.findOneByOrFail({ id: _follower.id }),
this.usersRepository.findOneByOrFail({ id: _followee.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)) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), 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: { follower: {
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
}, },
silent = false,
): Promise<void> { ): Promise<void> {
if (follower.id === followee.id) return; if (follower.id === followee.id) return;
@ -233,7 +238,7 @@ export class UserFollowingService implements OnModuleInit {
this.perUserFollowingChart.update(follower, followee, true); this.perUserFollowingChart.update(follower, followee, true);
// Publish follow event // Publish follow event
if (this.userEntityService.isLocalUser(follower)) { if (this.userEntityService.isLocalUser(follower) && !silent) {
this.userEntityService.pack(followee.id, follower, { this.userEntityService.pack(followee.id, follower, {
detail: true, detail: true,
}).then(async packed => { }).then(async packed => {
@ -410,7 +415,7 @@ export class UserFollowingService implements OnModuleInit {
} }
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { 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); this.queueService.deliver(follower, content, followee.inbox, false);
} }
} }

View file

@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { ProxyAccountService } from '@/core/ProxyAccountService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { QueueService } from '@/core/QueueService.js';
@Injectable() @Injectable()
export class UserListService { export class UserListService {
@ -29,6 +30,7 @@ export class UserListService {
private roleService: RoleService, private roleService: RoleService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private proxyAccountService: ProxyAccountService, private proxyAccountService: ProxyAccountService,
private queueService: QueueService,
) { ) {
} }
@ -47,14 +49,14 @@ export class UserListService {
userId: target.id, userId: target.id,
userListId: list.id, userListId: list.id,
} as UserListJoining); } as UserListJoining);
this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
if (this.userEntityService.isRemoteUser(target)) { if (this.userEntityService.isRemoteUser(target)) {
const proxy = await this.proxyAccountService.fetch(); const proxy = await this.proxyAccountService.fetch();
if (proxy) { if (proxy) {
this.userFollowingService.follow(proxy, target); this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]);
} }
} }
} }

View file

@ -84,7 +84,7 @@ export class ChannelEntityService {
} : {}), } : {}),
...(detailed ? { ...(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)),
} : {}), } : {}),
}; };
} }

View file

@ -8,7 +8,7 @@ export class RedisKVCache<T> {
private memoryCache: MemoryKVCache<T>; private memoryCache: MemoryKVCache<T>;
private fetcher: (key: string) => Promise<T>; private fetcher: (key: string) => Promise<T>;
private toRedisConverter: (value: T) => string; 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: { constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: {
lifetime: RedisKVCache<T>['lifetime']; lifetime: RedisKVCache<T>['lifetime'];
@ -92,7 +92,7 @@ export class RedisSingleCache<T> {
private memoryCache: MemorySingleCache<T>; private memoryCache: MemorySingleCache<T>;
private fetcher: () => Promise<T>; private fetcher: () => Promise<T>;
private toRedisConverter: (value: T) => string; 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: { constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: {
lifetime: RedisSingleCache<T>['lifetime']; lifetime: RedisSingleCache<T>['lifetime'];

View file

@ -52,8 +52,10 @@ export class DbQueueProcessorsService {
q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done)); q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done));
q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.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('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('importMuting', (job, done) => this.importMutingProcessorService.process(job, done));
q.process('importBlocking', (job, done) => this.importBlockingProcessorService.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('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done));
q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done)); q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done));
q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job)); q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job));

View file

@ -4,6 +4,7 @@ import { GlobalModule } from '@/GlobalModule.js';
import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueLoggerService } from './QueueLoggerService.js';
import { QueueProcessorService } from './QueueProcessorService.js'; import { QueueProcessorService } from './QueueProcessorService.js';
import { DbQueueProcessorsService } from './DbQueueProcessorsService.js'; import { DbQueueProcessorsService } from './DbQueueProcessorsService.js';
import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js';
import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js'; import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
@ -32,6 +33,7 @@ import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessor
import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
@Module({ @Module({
imports: [ imports: [
@ -61,9 +63,11 @@ import { ExportFavoritesProcessorService } from './processors/ExportFavoritesPro
DeleteAccountProcessorService, DeleteAccountProcessorService,
DeleteFileProcessorService, DeleteFileProcessorService,
CleanRemoteFilesProcessorService, CleanRemoteFilesProcessorService,
RelationshipProcessorService,
SystemQueueProcessorsService, SystemQueueProcessorsService,
ObjectStorageQueueProcessorsService, ObjectStorageQueueProcessorsService,
DbQueueProcessorsService, DbQueueProcessorsService,
RelationshipQueueProcessorsService,
WebhookDeliverProcessorService, WebhookDeliverProcessorService,
EndedPollNotificationProcessorService, EndedPollNotificationProcessorService,
DeliverProcessorService, DeliverProcessorService,

View file

@ -13,6 +13,7 @@ import { EndedPollNotificationProcessorService } from './processors/EndedPollNot
import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueLoggerService } from './QueueLoggerService.js';
import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js';
@Injectable() @Injectable()
export class QueueProcessorService { export class QueueProcessorService {
@ -27,6 +28,7 @@ export class QueueProcessorService {
private systemQueueProcessorsService: SystemQueueProcessorsService, private systemQueueProcessorsService: SystemQueueProcessorsService,
private objectStorageQueueProcessorsService: ObjectStorageQueueProcessorsService, private objectStorageQueueProcessorsService: ObjectStorageQueueProcessorsService,
private dbQueueProcessorsService: DbQueueProcessorsService, private dbQueueProcessorsService: DbQueueProcessorsService,
private relationshipQueueProcessorsService: RelationshipQueueProcessorsService,
private webhookDeliverProcessorService: WebhookDeliverProcessorService, private webhookDeliverProcessorService: WebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
private deliverProcessorService: DeliverProcessorService, private deliverProcessorService: DeliverProcessorService,
@ -52,14 +54,15 @@ export class QueueProcessorService {
}; };
} }
} }
const systemLogger = this.logger.createSubLogger('system'); const systemLogger = this.logger.createSubLogger('system');
const deliverLogger = this.logger.createSubLogger('deliver'); const deliverLogger = this.logger.createSubLogger('deliver');
const webhookLogger = this.logger.createSubLogger('webhook'); const webhookLogger = this.logger.createSubLogger('webhook');
const inboxLogger = this.logger.createSubLogger('inbox'); const inboxLogger = this.logger.createSubLogger('inbox');
const dbLogger = this.logger.createSubLogger('db'); const dbLogger = this.logger.createSubLogger('db');
const relationshipLogger = this.logger.createSubLogger('relationship');
const objectStorageLogger = this.logger.createSubLogger('objectStorage'); const objectStorageLogger = this.logger.createSubLogger('objectStorage');
this.queueService.systemQueue this.queueService.systemQueue
.on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`)) .on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => systemLogger.debug(`active id=${job.id}`)) .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('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('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`)); .on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`));
this.queueService.deliverQueue this.queueService.deliverQueue
.on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`)) .on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .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('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('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}`)); .on('stalled', (job) => deliverLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`));
this.queueService.inboxQueue this.queueService.inboxQueue
.on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`)) .on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`)) .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('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('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'}`)); .on('stalled', (job) => inboxLogger.warn(`stalled ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`));
this.queueService.dbQueue this.queueService.dbQueue
.on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`)) .on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => dbLogger.debug(`active id=${job.id}`)) .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('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('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`)); .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 this.queueService.objectStorageQueue
.on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`)) .on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) .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('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('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`)); .on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`));
this.queueService.webhookDeliverQueue this.queueService.webhookDeliverQueue
.on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`)) .on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`))
.on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .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('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('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}`)); .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.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.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job));
this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done)); this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done));
this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job)); this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job));
this.dbQueueProcessorsService.start(this.queueService.dbQueue); this.dbQueueProcessorsService.start(this.queueService.dbQueue);
this.relationshipQueueProcessorsService.start(this.queueService.relationshipQueue);
this.objectStorageQueueProcessorsService.start(this.queueService.objectStorageQueue); this.objectStorageQueueProcessorsService.start(this.queueService.objectStorageQueue);
this.queueService.systemQueue.add('tickCharts', { this.queueService.systemQueue.add('tickCharts', {
}, { }, {
repeat: { cron: '55 * * * *' }, repeat: { cron: '55 * * * *' },
removeOnComplete: true, removeOnComplete: true,
}); });
this.queueService.systemQueue.add('resyncCharts', { this.queueService.systemQueue.add('resyncCharts', {
}, { }, {
repeat: { cron: '0 0 * * *' }, repeat: { cron: '0 0 * * *' },
removeOnComplete: true, removeOnComplete: true,
}); });
this.queueService.systemQueue.add('cleanCharts', { this.queueService.systemQueue.add('cleanCharts', {
}, { }, {
repeat: { cron: '0 0 * * *' }, repeat: { cron: '0 0 * * *' },
@ -138,19 +150,19 @@ export class QueueProcessorService {
repeat: { cron: '0 0 * * *' }, repeat: { cron: '0 0 * * *' },
removeOnComplete: true, removeOnComplete: true,
}); });
this.queueService.systemQueue.add('clean', { this.queueService.systemQueue.add('clean', {
}, { }, {
repeat: { cron: '0 0 * * *' }, repeat: { cron: '0 0 * * *' },
removeOnComplete: true, removeOnComplete: true,
}); });
this.queueService.systemQueue.add('checkExpiredMutings', { this.queueService.systemQueue.add('checkExpiredMutings', {
}, { }, {
repeat: { cron: '*/5 * * * *' }, repeat: { cron: '*/5 * * * *' },
removeOnComplete: true, removeOnComplete: true,
}); });
this.systemQueueProcessorsService.start(this.queueService.systemQueue); this.systemQueueProcessorsService.start(this.queueService.systemQueue);
} }
} }

View file

@ -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));
}
}

View file

@ -7,7 +7,7 @@ import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
@ -31,7 +31,7 @@ export class DeleteDriveFilesProcessorService {
} }
@bindThis @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} ...`); this.logger.info(`Deleting drive files of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View file

@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
@ -36,7 +36,7 @@ export class ExportBlockingProcessorService {
} }
@bindThis @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} ...`); this.logger.info(`Exporting blocking of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View file

@ -13,7 +13,7 @@ import type { Note } from '@/models/entities/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
@Injectable() @Injectable()
export class ExportFavoritesProcessorService { export class ExportFavoritesProcessorService {
@ -42,7 +42,7 @@ export class ExportFavoritesProcessorService {
} }
@bindThis @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} ...`); this.logger.info(`Exporting favorites of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View file

@ -12,7 +12,7 @@ import type { Following } from '@/models/entities/Following.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbExportFollowingData } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
@ -40,7 +40,7 @@ export class ExportFollowingProcessorService {
} }
@bindThis @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} ...`); this.logger.info(`Exporting following of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View file

@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
@ -39,7 +39,7 @@ export class ExportMutingProcessorService {
} }
@bindThis @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} ...`); this.logger.info(`Exporting muting of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View file

@ -13,7 +13,7 @@ import type { Note } from '@/models/entities/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
@Injectable() @Injectable()
export class ExportNotesProcessorService { export class ExportNotesProcessorService {
@ -39,7 +39,7 @@ export class ExportNotesProcessorService {
} }
@bindThis @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} ...`); this.logger.info(`Exporting notes of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View file

@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserJobData } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
@ -39,7 +39,7 @@ export class ExportUserListsProcessorService {
} }
@bindThis @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} ...`); this.logger.info(`Exporting user lists of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View file

@ -1,38 +1,31 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository, BlockingsRepository, DriveFilesRepository } from '@/models/index.js'; import type { UsersRepository, DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { DownloadService } from '@/core/DownloadService.js'; import { DownloadService } from '@/core/DownloadService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserImportJobData } from '../types.js'; import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { QueueService } from '@/core/QueueService.js';
@Injectable() @Injectable()
export class ImportBlockingProcessorService { export class ImportBlockingProcessorService {
private logger: Logger; private logger: Logger;
constructor( constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
private queueService: QueueService,
private utilityService: UtilityService, private utilityService: UtilityService,
private userBlockingService: UserBlockingService,
private remoteUserResolveService: RemoteUserResolveService, private remoteUserResolveService: RemoteUserResolveService,
private downloadService: DownloadService, private downloadService: DownloadService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
@ -59,46 +52,50 @@ export class ImportBlockingProcessorService {
} }
const csv = await this.downloadService.downloadTextFile(file.url); const csv = await this.downloadService.downloadTextFile(file.url);
const targets = csv.trim().split('\n');
this.queueService.createImportBlockingToDbJob({ id: user.id }, targets);
let linenum = 0; this.logger.succ('Import jobs created');
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');
done(); 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}`);
}
}
} }

View file

@ -2,34 +2,30 @@ import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository, DriveFilesRepository } from '@/models/index.js'; import type { UsersRepository, DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { DownloadService } from '@/core/DownloadService.js'; import { DownloadService } from '@/core/DownloadService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type Bull from 'bull'; import type Bull from 'bull';
import type { DbUserImportJobData } from '../types.js'; import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { QueueService } from '@/core/QueueService.js';
@Injectable() @Injectable()
export class ImportFollowingProcessorService { export class ImportFollowingProcessorService {
private logger: Logger; private logger: Logger;
constructor( constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
private queueService: QueueService,
private utilityService: UtilityService, private utilityService: UtilityService,
private userFollowingService: UserFollowingService,
private remoteUserResolveService: RemoteUserResolveService, private remoteUserResolveService: RemoteUserResolveService,
private downloadService: DownloadService, private downloadService: DownloadService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
@ -56,46 +52,50 @@ export class ImportFollowingProcessorService {
} }
const csv = await this.downloadService.downloadTextFile(file.url); const csv = await this.downloadService.downloadTextFile(file.url);
const targets = csv.trim().split('\n');
this.queueService.createImportFollowingToDbJob({ id: user.id }, targets);
let linenum = 0; this.logger.succ('Import jobs created');
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');
done(); 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}`);
}
}
} }

View file

@ -66,11 +66,13 @@ export class ImportMutingProcessorService {
const acct = line.split(',')[0].trim(); const acct = line.split(',')[0].trim();
const { username, host } = Acct.parse(acct); 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(), host: IsNull(),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}) : await this.usersRepository.findOneBy({ }) : await this.usersRepository.findOneBy({
host: this.utilityService.toPuny(host!), host: this.utilityService.toPuny(host),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}); });

View file

@ -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';
}
}

View file

@ -21,9 +21,39 @@ export type InboxJobData = {
signature: httpSignature.IParsedSignature; 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; user: ThinUser;
excludeMuting: boolean; excludeMuting: boolean;
excludeInactive: boolean; excludeInactive: boolean;
@ -39,6 +69,11 @@ export type DbUserImportJobData = {
fileId: DriveFile['id']; fileId: DriveFile['id'];
}; };
export type DbUserImportToDbJobData = {
user: ThinUser;
target: string;
};
export type ObjectStorageJobData = ObjectStorageFileJobData | Record<string, unknown>; export type ObjectStorageJobData = ObjectStorageFileJobData | Record<string, unknown>;
export type ObjectStorageFileJobData = { export type ObjectStorageFileJobData = {

View file

@ -6,7 +6,7 @@ import { Brackets, In, IsNull, LessThan, Not } from 'typeorm';
import accepts from 'accepts'; import accepts from 'accepts';
import vary from 'vary'; import vary from 'vary';
import { DI } from '@/di-symbols.js'; 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 * as url from '@/misc/prelude/url.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@ -54,6 +54,9 @@ export class ActivityPubServerService {
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
private utilityService: UtilityService, private utilityService: UtilityService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
@ -205,22 +208,22 @@ export class ActivityPubServerService {
reply.code(400); reply.code(400);
return; return;
} }
const page = request.query.page === 'true'; const page = request.query.page === 'true';
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
id: userId, id: userId,
host: IsNull(), host: IsNull(),
}); });
if (user == null) { if (user == null) {
reply.code(404); reply.code(404);
return; return;
} }
//#region Check ff visibility //#region Check ff visibility
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
if (profile.ffVisibility === 'private') { if (profile.ffVisibility === 'private') {
reply.code(403); reply.code(403);
reply.header('Cache-Control', 'public, max-age=30'); reply.header('Cache-Control', 'public, max-age=30');
@ -231,31 +234,31 @@ export class ActivityPubServerService {
return; return;
} }
//#endregion //#endregion
const limit = 10; const limit = 10;
const partOf = `${this.config.url}/users/${userId}/following`; const partOf = `${this.config.url}/users/${userId}/following`;
if (page) { if (page) {
const query = { const query = {
followerId: user.id, followerId: user.id,
} as FindOptionsWhere<Following>; } as FindOptionsWhere<Following>;
// カーソルが指定されている場合 // カーソルが指定されている場合
if (cursor) { if (cursor) {
query.id = LessThan(cursor); query.id = LessThan(cursor);
} }
// Get followings // Get followings
const followings = await this.followingsRepository.find({ const followings = await this.followingsRepository.find({
where: query, where: query,
take: limit + 1, take: limit + 1,
order: { id: -1 }, order: { id: -1 },
}); });
// 「次のページ」があるかどうか // 「次のページ」があるかどうか
const inStock = followings.length === limit + 1; const inStock = followings.length === limit + 1;
if (inStock) followings.pop(); if (inStock) followings.pop();
const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId))); const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId)));
const rendered = this.apRendererService.renderOrderedCollectionPage( const rendered = this.apRendererService.renderOrderedCollectionPage(
`${partOf}?${url.query({ `${partOf}?${url.query({
@ -269,7 +272,7 @@ export class ActivityPubServerService {
cursor: followings[followings.length - 1].id, cursor: followings[followings.length - 1].id,
})}` : undefined, })}` : undefined,
); );
this.setResponseType(request, reply); this.setResponseType(request, reply);
return (this.apRendererService.addContext(rendered)); return (this.apRendererService.addContext(rendered));
} else { } else {
@ -330,33 +333,33 @@ export class ActivityPubServerService {
reply.code(400); reply.code(400);
return; return;
} }
const untilId = request.query.until_id; const untilId = request.query.until_id;
if (untilId != null && typeof untilId !== 'string') { if (untilId != null && typeof untilId !== 'string') {
reply.code(400); reply.code(400);
return; return;
} }
const page = request.query.page === 'true'; const page = request.query.page === 'true';
if (countIf(x => x != null, [sinceId, untilId]) > 1) { if (countIf(x => x != null, [sinceId, untilId]) > 1) {
reply.code(400); reply.code(400);
return; return;
} }
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
id: userId, id: userId,
host: IsNull(), host: IsNull(),
}); });
if (user == null) { if (user == null) {
reply.code(404); reply.code(404);
return; return;
} }
const limit = 20; const limit = 20;
const partOf = `${this.config.url}/users/${userId}/outbox`; const partOf = `${this.config.url}/users/${userId}/outbox`;
if (page) { if (page) {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
.andWhere('note.userId = :userId', { userId: user.id }) .andWhere('note.userId = :userId', { userId: user.id })
@ -365,11 +368,11 @@ export class ActivityPubServerService {
.orWhere('note.visibility = \'home\''); .orWhere('note.visibility = \'home\'');
})) }))
.andWhere('note.localOnly = FALSE'); .andWhere('note.localOnly = FALSE');
const notes = await query.take(limit).getMany(); const notes = await query.take(limit).getMany();
if (sinceId) notes.reverse(); if (sinceId) notes.reverse();
const activities = await Promise.all(notes.map(note => this.packActivity(note))); const activities = await Promise.all(notes.map(note => this.packActivity(note)));
const rendered = this.apRendererService.renderOrderedCollectionPage( const rendered = this.apRendererService.renderOrderedCollectionPage(
`${partOf}?${url.query({ `${partOf}?${url.query({
@ -387,7 +390,7 @@ export class ActivityPubServerService {
until_id: notes[notes.length - 1].id, until_id: notes[notes.length - 1].id,
})}` : undefined, })}` : undefined,
); );
this.setResponseType(request, reply); this.setResponseType(request, reply);
return (this.apRendererService.addContext(rendered)); return (this.apRendererService.addContext(rendered));
} else { } else {
@ -457,7 +460,7 @@ export class ActivityPubServerService {
// note // note
fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
vary(reply.raw, 'Accept'); vary(reply.raw, 'Accept');
const note = await this.notesRepository.findOneBy({ const note = await this.notesRepository.findOneBy({
id: request.params.note, id: request.params.note,
visibility: In(['public', 'home']), visibility: In(['public', 'home']),
@ -639,6 +642,41 @@ export class ActivityPubServerService {
return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); 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(); done();
} }
} }

View file

@ -34,6 +34,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
import { UserListChannelService } from './api/stream/channels/user-list.js'; import { UserListChannelService } from './api/stream/channels/user-list.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
@Module({ @Module({
imports: [ imports: [
@ -42,6 +44,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
], ],
providers: [ providers: [
ClientServerService, ClientServerService,
ClientLoggerService,
FeedService, FeedService,
UrlPreviewService, UrlPreviewService,
ActivityPubServerService, ActivityPubServerService,
@ -67,6 +70,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
DriveChannelService, DriveChannelService,
GlobalTimelineChannelService, GlobalTimelineChannelService,
HashtagChannelService, HashtagChannelService,
RoleTimelineChannelService,
HomeTimelineChannelService, HomeTimelineChannelService,
HybridTimelineChannelService, HybridTimelineChannelService,
LocalTimelineChannelService, LocalTimelineChannelService,

View file

@ -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_list from './endpoints/roles/list.js';
import * as ep___roles_show from './endpoints/roles/show.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_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___requestResetPassword from './endpoints/request-reset-password.js';
import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetDb from './endpoints/reset-db.js';
import * as ep___resetPassword from './endpoints/reset-password.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_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_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_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 $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default };
const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default }; const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default };
const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.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_list,
$roles_show, $roles_show,
$roles_users, $roles_users,
$roles_notes,
$requestResetPassword, $requestResetPassword,
$resetDb, $resetDb,
$resetPassword, $resetPassword,
@ -1298,6 +1301,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$roles_list, $roles_list,
$roles_show, $roles_show,
$roles_users, $roles_users,
$roles_notes,
$requestResetPassword, $requestResetPassword,
$resetDb, $resetDb,
$resetPassword, $resetPassword,

View file

@ -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_list from './endpoints/roles/list.js';
import * as ep___roles_show from './endpoints/roles/show.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_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___requestResetPassword from './endpoints/request-reset-password.js';
import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetDb from './endpoints/reset-db.js';
import * as ep___resetPassword from './endpoints/reset-password.js'; import * as ep___resetPassword from './endpoints/reset-password.js';
@ -626,6 +627,7 @@ const eps = [
['roles/list', ep___roles_list], ['roles/list', ep___roles_list],
['roles/show', ep___roles_show], ['roles/show', ep___roles_show],
['roles/users', ep___roles_users], ['roles/users', ep___roles_users],
['roles/notes', ep___roles_notes],
['request-reset-password', ep___requestResetPassword], ['request-reset-password', ep___requestResetPassword],
['reset-db', ep___resetDb], ['reset-db', ep___resetDb],
['reset-password', ep___resetPassword], ['reset-password', ep___resetPassword],

View file

@ -1,8 +1,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; import type { FollowingsRepository, UsersRepository } from '@/models/index.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { QueueService } from '@/core/QueueService.js';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
@ -29,7 +29,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private userFollowingService: UserFollowingService, private queueService: QueueService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const followings = await this.followingsRepository.findBy({ 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 }), this.usersRepository.findOneByOrFail({ id: f.followeeId }),
]))); ])));
for (const pair of pairs) { this.queueService.createUnfollowJob(pairs.map(p => ({ to: p[0], from: p[1], silent: true })));
this.userFollowingService.unfollow(pair[0], pair[1]);
}
}); });
} }
} }

View file

@ -1,15 +1,15 @@
import { IsNull, Not } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; import type { UsersRepository, FollowingsRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.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 { ModerationLogService } from '@/core/ModerationLogService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js'; import { UserSuspendService } from '@/core/UserSuspendService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { QueueService } from '@/core/QueueService.js';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
@ -36,12 +36,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
private userFollowingService: UserFollowingService,
private userSuspendService: UserSuspendService, private userSuspendService: UserSuspendService,
private roleService: RoleService, private roleService: RoleService,
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private globalEventService: GlobalEventService, private queueService: QueueService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId }); const user = await this.usersRepository.findOneBy({ id: ps.userId });
@ -71,20 +69,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@bindThis @bindThis
private async unFollowAll(follower: User) { private async unFollowAll(follower: User) {
const followings = await this.followingsRepository.findBy({ const followings = await this.followingsRepository.find({
followerId: follower.id, where: {
followerId: follower.id,
followeeId: Not(IsNull()),
},
}); });
const jobs: RelationshipJobData[] = [];
for (const following of followings) { for (const following of followings) {
const followee = await this.usersRepository.findOneBy({ if (following.followeeId && following.followerId) {
id: following.followeeId, jobs.push({
}); from: { id: following.followerId },
to: { id: following.followeeId },
if (followee == null) { silent: true,
throw `Cant find followee ${following.followeeId}`; });
} }
await this.userFollowingService.unfollow(follower, followee, true);
} }
this.queueService.createUnfollowJob(jobs);
} }
} }

View file

@ -76,17 +76,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchAntenna); 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( const noteIdsRes = await this.redisClient.xrevrange(
`antennaTimeline:${antenna.id}`, `antennaTimeline:${antenna.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-', ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 'COUNT', limit);
if (noteIdsRes.length === 0) { if (noteIdsRes.length === 0) {
return []; 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) { if (noteIds.length === 0) {
return []; return [];

View file

@ -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 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 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( const notificationsRes = await this.redisClient.xrevrange(
`notificationTimeline:${me.id}`, `notificationTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
'-', '-',
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 'COUNT', limit);
if (notificationsRes.length === 0) { if (notificationsRes.length === 0) {
return []; return [];

View 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);
});
}
}

View file

@ -41,8 +41,6 @@ export const paramDef = {
], ],
} as const; } as const;
// TODO: avatar,bannerをJOINしたいけどエラーになる
// eslint-disable-next-line import/no-default-export // eslint-disable-next-line import/no-default-export
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { export default class extends Endpoint<typeof meta, typeof paramDef> {

View file

@ -13,6 +13,7 @@ import { UserListChannelService } from './channels/user-list.js';
import { AntennaChannelService } from './channels/antenna.js'; import { AntennaChannelService } from './channels/antenna.js';
import { DriveChannelService } from './channels/drive.js'; import { DriveChannelService } from './channels/drive.js';
import { HashtagChannelService } from './channels/hashtag.js'; import { HashtagChannelService } from './channels/hashtag.js';
import { RoleTimelineChannelService } from './channels/role-timeline.js';
@Injectable() @Injectable()
export class ChannelsService { export class ChannelsService {
@ -24,6 +25,7 @@ export class ChannelsService {
private globalTimelineChannelService: GlobalTimelineChannelService, private globalTimelineChannelService: GlobalTimelineChannelService,
private userListChannelService: UserListChannelService, private userListChannelService: UserListChannelService,
private hashtagChannelService: HashtagChannelService, private hashtagChannelService: HashtagChannelService,
private roleTimelineChannelService: RoleTimelineChannelService,
private antennaChannelService: AntennaChannelService, private antennaChannelService: AntennaChannelService,
private channelChannelService: ChannelChannelService, private channelChannelService: ChannelChannelService,
private driveChannelService: DriveChannelService, private driveChannelService: DriveChannelService,
@ -43,6 +45,7 @@ export class ChannelsService {
case 'globalTimeline': return this.globalTimelineChannelService; case 'globalTimeline': return this.globalTimelineChannelService;
case 'userList': return this.userListChannelService; case 'userList': return this.userListChannelService;
case 'hashtag': return this.hashtagChannelService; case 'hashtag': return this.hashtagChannelService;
case 'roleTimeline': return this.roleTimelineChannelService;
case 'antenna': return this.antennaChannelService; case 'antenna': return this.antennaChannelService;
case 'channel': return this.channelChannelService; case 'channel': return this.channelChannelService;
case 'drive': return this.driveChannelService; case 'drive': return this.driveChannelService;

View file

@ -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,
);
}
}

View file

@ -148,6 +148,10 @@ export interface AntennaStreamTypes {
note: Note; note: Note;
} }
export interface RoleTimelineStreamTypes {
note: Packed<'Note'>;
}
export interface AdminStreamTypes { export interface AdminStreamTypes {
newAbuseUserReport: { newAbuseUserReport: {
id: AbuseUserReport['id']; id: AbuseUserReport['id'];
@ -168,7 +172,7 @@ type EventUnionFromDictionary<
> = U[keyof U]; > = U[keyof U];
// redis通すとDateのインスタンスはstringに変換されるので // redis通すとDateのインスタンスはstringに変換されるので
type Serialized<T> = { export type Serialized<T> = {
[K in keyof T]: [K in keyof T]:
T[K] extends Date T[K] extends Date
? string ? string
@ -209,6 +213,10 @@ export type StreamMessages = {
name: `userListStream:${UserList['id']}`; name: `userListStream:${UserList['id']}`;
payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>; payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>;
}; };
roleTimeline: {
name: `roleTimelineStream:${Role['id']}`;
payload: EventUnionFromDictionary<SerializedAll<RoleTimelineStreamTypes>>;
};
antenna: { antenna: {
name: `antennaStream:${Antenna['id']}`; name: `antennaStream:${Antenna['id']}`;
payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>; payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>;

View 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');
}
}

View file

@ -2,6 +2,7 @@ import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import { createBullBoard } from '@bull-board/api'; import { createBullBoard } from '@bull-board/api';
import { BullAdapter } from '@bull-board/api/bullAdapter.js'; import { BullAdapter } from '@bull-board/api/bullAdapter.js';
import { FastifyAdapter } from '@bull-board/fastify'; import { FastifyAdapter } from '@bull-board/fastify';
@ -27,6 +28,7 @@ import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityServi
import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.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 { deepClone } from '@/misc/clone.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
@ -35,6 +37,7 @@ import manifest from './manifest.json' assert { type: 'json' };
import { FeedService } from './FeedService.js'; import { FeedService } from './FeedService.js';
import { UrlPreviewService } from './UrlPreviewService.js'; import { UrlPreviewService } from './UrlPreviewService.js';
import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify';
import { ClientLoggerService } from './ClientLoggerService.js';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
@ -47,6 +50,8 @@ const viteOut = `${_dirname}/../../../../../built/_vite_/`;
@Injectable() @Injectable()
export class ClientServerService { export class ClientServerService {
private logger: Logger;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@ -86,6 +91,7 @@ export class ClientServerService {
private urlPreviewService: UrlPreviewService, private urlPreviewService: UrlPreviewService,
private feedService: FeedService, private feedService: FeedService,
private roleService: RoleService, private roleService: RoleService,
private clientLoggerService: ClientLoggerService,
@Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@ -663,6 +669,24 @@ export class ClientServerService {
return await renderBase(reply); 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(); done();
} }
} }

View 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%;
}
}

View 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

View 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)');
});

View file

@ -6,6 +6,7 @@ import WebSocket from 'ws';
import fetch, { Blob, File, RequestInit } from 'node-fetch'; import fetch, { Blob, File, RequestInit } from 'node-fetch';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import { entities } from '../src/postgres.js'; import { entities } from '../src/postgres.js';
import { loadConfig } from '../src/config.js'; import { loadConfig } from '../src/config.js';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
@ -31,12 +32,12 @@ export type ApiRequest = {
}; };
export const successfulApiCall = async <T, >(request: ApiRequest, assertion: { export const successfulApiCall = async <T, >(request: ApiRequest, assertion: {
status: number, status?: number,
} = { status: 200 }): Promise<T> => { } = {}): Promise<T> => {
const { endpoint, parameters, user } = request; const { endpoint, parameters, user } = request;
const { status } = assertion;
const res = await api(endpoint, parameters, user); 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; return res.body;
}; };
@ -188,6 +189,36 @@ export const channel = async (user: any, channel: any = {}): Promise<any> => {
return res.body; 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 { interface UploadOptions {
/** Optional, absolute path or relative from ./resources/ */ /** Optional, absolute path or relative from ./resources/ */
path?: string | URL; path?: string | URL;

View file

@ -31,7 +31,7 @@
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import VuePlyr from 'vue-plyr'; import VuePlyr from 'vue-plyr';
import { ColdDeviceStorage } from '@/store'; import { soundConfigStore } from '@/scripts/sound';
import 'vue-plyr/dist/vue-plyr.css'; import 'vue-plyr/dist/vue-plyr.css';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
@ -44,11 +44,11 @@ const audioEl = $shallowRef<HTMLAudioElement | null>();
let hide = $ref(true); let hide = $ref(true);
function volumechange() { function volumechange() {
if (audioEl) ColdDeviceStorage.set('mediaVolume', audioEl.volume); if (audioEl) soundConfigStore.set('mediaVolume', audioEl.volume);
} }
onMounted(() => { onMounted(() => {
if (audioEl) audioEl.volume = ColdDeviceStorage.get('mediaVolume'); if (audioEl) audioEl.volume = soundConfigStore.state.mediaVolume;
}); });
</script> </script>

View file

@ -15,6 +15,7 @@ const props = defineProps<{
list?: string; list?: string;
antenna?: string; antenna?: string;
channel?: string; channel?: string;
role?: string;
sound?: boolean; sound?: boolean;
}>(); }>();
@ -121,6 +122,15 @@ if (props.src === 'antenna') {
channelId: props.channel, channelId: props.channel,
}); });
connection.on('note', prepend); 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 = { const pagination = {

View file

@ -5,7 +5,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { getStaticImageUrl } from '@/scripts/media-proxy'; import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { customEmojis } from '@/custom-emojis'; import { customEmojis } from '@/custom-emojis';
@ -15,25 +15,38 @@ const props = defineProps<{
noStyle?: boolean; noStyle?: boolean;
host?: string | null; host?: string | null;
url?: string; url?: string;
useOriginalSize?: boolean;
}>(); }>();
const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substr(1, props.name.length - 2) : props.name).replace('@.', '')); 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(() => { const rawUrl = computed(() => {
if (props.url) { if (props.url) {
return 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 customEmojis.value.find(x => x.name === customEmojiName.value)?.url ?? null;
} }
return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`; return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`;
}); });
const url = computed(() => const url = computed(() => {
defaultStore.reactiveState.disableShowingAnimatedImages.value && rawUrl.value if (rawUrl.value == null) return null;
? getStaticImageUrl(rawUrl.value)
: rawUrl.value, 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}:`); const alt = computed(() => `:${customEmojiName.value}:`);
let errored = $ref(url.value == null); let errored = $ref(url.value == null);

View file

@ -51,6 +51,10 @@ export default defineComponent({
type: Object, type: Object,
default: null, default: null,
}, },
rootScale: {
type: Number,
default: 1,
}
}, },
render() { render() {
@ -65,7 +69,12 @@ export default defineComponent({
const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm; 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) { switch (token.type) {
case 'text': { case 'text': {
const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
@ -84,17 +93,17 @@ export default defineComponent({
} }
case 'bold': { case 'bold': {
return [h('b', genEl(token.children))]; return [h('b', genEl(token.children, scale))];
} }
case 'strike': { case 'strike': {
return [h('del', genEl(token.children))]; return [h('del', genEl(token.children, scale))];
} }
case 'italic': { case 'italic': {
return h('i', { return h('i', {
style: 'font-style: oblique;', style: 'font-style: oblique;',
}, genEl(token.children)); }, genEl(token.children, scale));
} }
case 'fn': { case 'fn': {
@ -155,17 +164,17 @@ export default defineComponent({
case 'x2': { case 'x2': {
return h('span', { return h('span', {
class: defaultStore.state.advancedMfm ? 'mfm-x2' : '', class: defaultStore.state.advancedMfm ? 'mfm-x2' : '',
}, genEl(token.children)); }, genEl(token.children, scale * 2));
} }
case 'x3': { case 'x3': {
return h('span', { return h('span', {
class: defaultStore.state.advancedMfm ? 'mfm-x3' : '', class: defaultStore.state.advancedMfm ? 'mfm-x3' : '',
}, genEl(token.children)); }, genEl(token.children, scale * 3));
} }
case 'x4': { case 'x4': {
return h('span', { return h('span', {
class: defaultStore.state.advancedMfm ? 'mfm-x4' : '', class: defaultStore.state.advancedMfm ? 'mfm-x4' : '',
}, genEl(token.children)); }, genEl(token.children, scale * 4));
} }
case 'font': { case 'font': {
const family = const family =
@ -182,7 +191,7 @@ export default defineComponent({
case 'blur': { case 'blur': {
return h('span', { return h('span', {
class: '_mfm_blur_', class: '_mfm_blur_',
}, genEl(token.children)); }, genEl(token.children, scale));
} }
case 'rainbow': { case 'rainbow': {
const speed = validTime(token.props.args.speed) ?? '1s'; const speed = validTime(token.props.args.speed) ?? '1s';
@ -191,9 +200,9 @@ export default defineComponent({
} }
case 'sparkle': { case 'sparkle': {
if (!useAnim) { 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': { case 'rotate': {
const degrees = parseFloat(token.props.args.deg ?? '90'); 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 x = Math.min(parseFloat(token.props.args.x ?? '1'), 5);
const y = Math.min(parseFloat(token.props.args.y ?? '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; break;
} }
case 'fg': { case 'fg': {
@ -231,24 +241,24 @@ export default defineComponent({
} }
} }
if (style == null) { if (style == null) {
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']); return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
} else { } else {
return h('span', { return h('span', {
style: 'display: inline-block; ' + style, style: 'display: inline-block; ' + style,
}, genEl(token.children)); }, genEl(token.children, scale));
} }
} }
case 'small': { case 'small': {
return [h('small', { return [h('small', {
style: 'opacity: 0.7;', style: 'opacity: 0.7;',
}, genEl(token.children))]; }, genEl(token.children, scale))];
} }
case 'center': { case 'center': {
return [h('div', { return [h('div', {
style: 'text-align:center;', style: 'text-align:center;',
}, genEl(token.children))]; }, genEl(token.children, scale))];
} }
case 'url': { case 'url': {
@ -264,7 +274,7 @@ export default defineComponent({
key: Math.random(), key: Math.random(),
url: token.props.url, url: token.props.url,
rel: 'nofollow noopener', rel: 'nofollow noopener',
}, genEl(token.children))]; }, genEl(token.children, scale))];
} }
case 'mention': { case 'mention': {
@ -303,11 +313,11 @@ export default defineComponent({
if (!this.nowrap) { if (!this.nowrap) {
return [h('div', { return [h('div', {
style: QUOTE_STYLE, style: QUOTE_STYLE,
}, genEl(token.children))]; }, genEl(token.children, scale))];
} else { } else {
return [h('span', { return [h('span', {
style: QUOTE_STYLE, style: QUOTE_STYLE,
}, genEl(token.children))]; }, genEl(token.children, scale))];
} }
} }
@ -319,6 +329,7 @@ export default defineComponent({
name: token.props.name, name: token.props.name,
normal: this.plain, normal: this.plain,
host: null, host: null,
useOriginalSize: scale >= 2.5,
})]; })];
} else { } else {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // 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, url: this.emojiUrls ? this.emojiUrls[token.props.name] : null,
normal: this.plain, normal: this.plain,
host: this.author.host, host: this.author.host,
useOriginalSize: scale >= 2.5,
})]; })];
} }
} }
@ -360,7 +372,7 @@ export default defineComponent({
} }
case 'plain': { case 'plain': {
return [h('span', genEl(token.children))]; return [h('span', genEl(token.children, scale))];
} }
default: { default: {
@ -373,6 +385,6 @@ export default defineComponent({
}).flat(Infinity) as (VNode | string)[]; }).flat(Infinity) as (VNode | string)[];
// Parse ast to DOM // Parse ast to DOM
return h('span', genEl(ast)); return h('span', genEl(ast, this.rootScale));
}, },
}); });

View file

@ -218,6 +218,7 @@ const patrons = [
'Ebise Lutica', 'Ebise Lutica',
'巣黒るい@リスケモ男の娘VTuber!', '巣黒るい@リスケモ男の娘VTuber!',
'ふぇいぽむ', 'ふぇいぽむ',
'依古田イコ',
]; ];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));

View file

@ -65,7 +65,7 @@ const props = defineProps<{
}>(); }>();
let key = $ref(''); let key = $ref('');
let tab = $ref('search'); let tab = $ref('featured');
let searchQuery = $ref(''); let searchQuery = $ref('');
let searchType = $ref('nameAndDescription'); let searchType = $ref('nameAndDescription');
let channelPagination = $ref(); let channelPagination = $ref();

View file

@ -1,7 +1,7 @@
<template> <template>
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<div class="lznhrdub"> <div>
<div v-if="tab === 'featured'"> <div v-if="tab === 'featured'">
<XFeatured/> <XFeatured/>
</div> </div>

View file

@ -1,13 +1,16 @@
<template> <template>
<MkStickyContainer> <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 class="_gaps_s">
<div v-if="role">{{ role.description }}</div> <div v-if="role">{{ role.description }}</div>
<MkUserList :pagination="users" :extractor="(item) => item.user"/> <MkUserList :pagination="users" :extractor="(item) => item.user"/>
</div> </div>
</MkSpacer> </MkSpacer>
<MkSpacer v-else-if="tab === 'timeline'" :content-max="700">
<MkTimeline ref="timeline" src="role" :role="props.role"/>
</MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
@ -16,11 +19,17 @@ import { computed, watch } from 'vue';
import * as os from '@/os'; import * as os from '@/os';
import MkUserList from '@/components/MkUserList.vue'; import MkUserList from '@/components/MkUserList.vue';
import { definePageMetadata } from '@/scripts/page-metadata'; 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; role: string;
}>(); initialTab?: string;
}>(), {
initialTab: 'users',
});
let tab = $ref(props.initialTab);
let role = $ref(); let role = $ref();
watch(() => props.role, () => { 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(() => ({ definePageMetadata(computed(() => ({
title: role?.name, title: role?.name,
icon: 'ti ti-badge', icon: 'ti ti-badge',

View file

@ -7,7 +7,7 @@
<FormSection> <FormSection>
<template #label>{{ i18n.ts.sounds }}</template> <template #label>{{ i18n.ts.sounds }}</template>
<div class="_gaps_s"> <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 #label>{{ i18n.t('_sfx.' + type) }}</template>
<template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template> <template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template>
@ -21,51 +21,44 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { Ref, computed, ref } from 'vue';
import XSound from './sounds.sound.vue'; import XSound from './sounds.sound.vue';
import MkRange from '@/components/MkRange.vue'; import MkRange from '@/components/MkRange.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue'; import FormSection from '@/components/form/section.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import { ColdDeviceStorage } from '@/store'; import { soundConfigStore } from '@/scripts/sound';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
const masterVolume = computed({ const masterVolume = computed(soundConfigStore.makeGetterSetter('sound_masterVolume'));
get: () => {
return ColdDeviceStorage.get('sound_masterVolume'); const soundsKeys = ['note', 'noteMy', 'notification', 'chat', 'chatBg', 'antenna', 'channel'] as const;
},
set: (value) => { const sounds = ref<Record<typeof soundsKeys[number], Ref<any>>>({
ColdDeviceStorage.set('sound_masterVolume', value); 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'); async function updated(type: keyof typeof sounds.value, sound) {
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) {
const v = { const v = {
type: sound.type, type: sound.type,
volume: sound.volume, volume: sound.volume,
}; };
ColdDeviceStorage.set('sound_' + type, v); soundConfigStore.set(`sound_${type}`, v);
sounds.value[type] = v; sounds.value[type] = v;
} }
function reset() { function reset() {
for (const sound of Object.keys(sounds.value)) { for (const sound of Object.keys(sounds.value) as Array<keyof typeof sounds.value>) {
const v = ColdDeviceStorage.default['sound_' + sound]; const v = soundConfigStore.def[`sound_${sound}`].default;
ColdDeviceStorage.set('sound_' + sound, v); soundConfigStore.set(`sound_${sound}`, v);
sounds.value[sound] = v; sounds.value[sound] = v;
} }
} }

View file

@ -2,7 +2,7 @@ import { query } from '@/scripts/url';
import { url } from '@/config'; import { url } from '@/config';
import { instance } from '@/instance'; 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`; const localProxy = `${url}/proxy`;
if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) { 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' : 'image.webp'
}?${query({ }?${query({
url: imageUrl, url: imageUrl,
fallback: '1', ...(!noFallback ? { 'fallback': '1' } : {}),
...(type ? { [type]: '1' } : {}), ...(type ? { [type]: '1' } : {}),
...(mustOrigin ? { origin: '1' } : {}), ...(mustOrigin ? { origin: '1' } : {}),
})}`; })}`;

View file

@ -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>(); const cache = new Map<string, HTMLAudioElement>();
@ -128,19 +180,20 @@ export function getAudio(file: string, useCache = true): HTMLAudioElement {
} }
export function setVolume(audio: HTMLAudioElement, volume: number): 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); audio.volume = masterVolume - ((1 - volume) * masterVolume);
return audio; return audio;
} }
export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification') { 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; if (sound.type == null) return;
playFile(sound.type, sound.volume); playFile(sound.type, sound.volume);
} }
export function playFile(file: string, volume: number) { export function playFile(file: string, volume: number) {
const masterVolume = ColdDeviceStorage.get('sound_masterVolume'); const masterVolume = soundConfigStore.state.sound_masterVolume;
if (masterVolume === 0) return; if (masterVolume === 0) return;
const audio = setVolume(getAudio(file), volume); const audio = setVolume(getAudio(file), volume);

View file

@ -343,15 +343,6 @@ export class ColdDeviceStorage {
darkTheme, darkTheme,
syncDeviceDarkMode: true, syncDeviceDarkMode: true,
plugins: [] as Plugin[], 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[] = []; public static watchers: Watcher[] = [];

View file

@ -152,6 +152,7 @@ const addColumn = async (ev) => {
'channel', 'channel',
'mentions', 'mentions',
'direct', 'direct',
'roleTimeline',
]; ];
const { canceled, result: column } = await os.select({ const { canceled, result: column } = await os.select({

View file

@ -10,6 +10,7 @@
<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> <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)"/> <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)"/> <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> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -23,6 +24,7 @@ import XNotificationsColumn from './notifications-column.vue';
import XWidgetsColumn from './widgets-column.vue'; import XWidgetsColumn from './widgets-column.vue';
import XMentionsColumn from './mentions-column.vue'; import XMentionsColumn from './mentions-column.vue';
import XDirectColumn from './direct-column.vue'; import XDirectColumn from './direct-column.vue';
import XRoleTimelineColumn from './role-timeline-column.vue';
import { Column } from './deck-store'; import { Column } from './deck-store';
defineProps<{ defineProps<{

View file

@ -22,6 +22,7 @@ export type Column = {
antennaId?: string; antennaId?: string;
listId?: string; listId?: string;
channelId?: string; channelId?: string;
roleId?: string;
includingTypes?: typeof notificationTypes[number][]; includingTypes?: typeof notificationTypes[number][];
tl?: 'home' | 'local' | 'social' | 'global'; tl?: 'home' | 'local' | 'social' | 'global';
}; };

View 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>

View file

@ -1,22 +1,20 @@
module.exports = { module.exports = {
root: true, root: true,
env: { env: {
"node": false node: false,
}, },
parserOptions: { parserOptions: {
"parser": "@typescript-eslint/parser", parser: '@typescript-eslint/parser',
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
project: ['./tsconfig.json'], project: ['./tsconfig.json'],
}, },
extends: [ extends: ['../shared/.eslintrc.js'],
"../shared/.eslintrc.js",
],
globals: { globals: {
"require": false, require: false,
"_DEV_": false, _DEV_: false,
"_LANGS_": false, _LANGS_: false,
"_VERSION_": false, _VERSION_: false,
"_ENV_": false, _ENV_: false,
"_PERF_PREFIX_": false, _PERF_PREFIX_: false,
} },
} };

View file

@ -1,3 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FIXME = any; type FIXME = any;
declare const _LANGS_: string[][]; declare const _LANGS_: string[][];

View file

@ -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}` : '')}`;
};

View file

@ -1,22 +1,20 @@
/* /*
* Notification manager for SW * Notification manager for SW
*/ */
import { swLang } from '@/scripts/lang'; import type { BadgeNames, PushNotificationDataMap } from '@/types';
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 { char2fileName } from '@/scripts/twemoji-base'; 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()) { for (const n of (await Promise.all(tags.map(tag => globalThis.registration.getNotifications({ tag })))).flat()) {
n.close(); 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: /* How to add a new badge:
* 1. Find the icon and download png from https://tabler-icons.io/ * 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; * 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'),` * 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); const n = await composeNotification(data);
if (n) { 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> { async function composeNotification(data: PushNotificationDataMap[keyof PushNotificationDataMap]): Promise<[string, NotificationOptions] | null> {
if (!swLang.i18n) swLang.fetchLocale(); const i18n = await (swLang.i18n ?? swLang.fetchLocale());
const i18n = await swLang.i18n as I18n<any>;
const { t } = i18n; const { t } = i18n;
switch (data.type) { switch (data.type) {
/* /*
@ -139,16 +136,16 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
if (reaction.startsWith(':')) { if (reaction.startsWith(':')) {
// カスタム絵文字の場合 // カスタム絵文字の場合
const name = reaction.substring(1, reaction.length - 1); const name = reaction.substring(1, reaction.length - 1);
badge = `${origin}/emoji/${name}.webp?${url.query({ const badgeUrl = new URL(`/emoji/${name}.webp`, origin);
badge: '1', badgeUrl.searchParams.set('badge', '1');
})}`; badge = badgeUrl.href;
reaction = name.split('@')[0]; reaction = name.split('@')[0];
} else { } else {
// Unicode絵文字の場合 // Unicode絵文字の場合
badge = `/twemoji-badge/${char2fileName(reaction)}.png`; 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'); 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 => { return new Promise<void>(async res => {
if (!swLang.i18n) swLang.fetchLocale(); const i18n = await (swLang.i18n ?? swLang.fetchLocale());
const i18n = await swLang.i18n as I18n<any>;
const { t } = i18n; const { t } = i18n;
await globalThis.registration.showNotification( await globalThis.registration.showNotification(

View file

@ -1,7 +1,10 @@
import { get } from 'idb-keyval'; import { get } from 'idb-keyval';
export async function getAccountFromId(id: string) { export async function getAccountFromId(id: string): Promise<{ token: string; id: string } | void> {
const accounts = await get('accounts') as { token: string; id: string; }[]; const accounts = await get<{ token: string; id: string }[]>('accounts');
if (!accounts) console.log('Accounts are not recorded'); if (!accounts) {
console.log('Accounts are not recorded');
return;
}
return accounts.find(e => e.id === id); return accounts.find(e => e.id === id);
} }

View file

@ -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; return user.name === '' ? user.username : user.name ?? user.username;
} }

View file

@ -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; public ts: T;
constructor(locale: T) { constructor(locale: T) {
@ -13,7 +15,8 @@ export class I18n<T extends Record<string, any>> {
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
public t(key: string, args?: Record<string, string>): string { public t(key: string, args?: Record<string, string>): string {
try { 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) { if (args) {
for (const [k, v] of Object.entries(args)) { for (const [k, v] of Object.entries(args)) {

View file

@ -2,7 +2,7 @@
* Language manager for SW * Language manager for SW
*/ */
import { get, set } from 'idb-keyval'; import { get, set } from 'idb-keyval';
import { I18n } from '@/scripts/i18n'; import { I18n, type Locale } from '@/scripts/i18n';
class SwLang { class SwLang {
public cacheName = `mk-cache-${_VERSION_}`; public cacheName = `mk-cache-${_VERSION_}`;
@ -12,19 +12,19 @@ class SwLang {
return prelang; return prelang;
}); });
public setLang(newLang: string) { public setLang(newLang: string): Promise<I18n<Locale>> {
this.lang = Promise.resolve(newLang); this.lang = Promise.resolve(newLang);
set('lang', newLang); set('lang', newLang);
return this.fetchLocale(); return this.fetchLocale();
} }
public i18n: Promise<I18n<any>> | null = null; public i18n: Promise<I18n> | null = null;
public fetchLocale() { public fetchLocale(): Promise<I18n<Locale>> {
return this.i18n = this._fetch(); return (this.i18n = this._fetch());
} }
private async _fetch() { private async _fetch(): Promise<I18n<Locale>> {
// Service Workerは何度も起動しそのたびにlocaleを読み込むので、CacheStorageを使う // Service Workerは何度も起動しそのたびにlocaleを読み込むので、CacheStorageを使う
const localeUrl = `/assets/locales/${await this.lang}.${_VERSION_}.json`; const localeUrl = `/assets/locales/${await this.lang}.${_VERSION_}.json`;
let localeRes = await caches.match(localeUrl); let localeRes = await caches.match(localeUrl);
@ -32,13 +32,13 @@ class SwLang {
// _DEV_がtrueの場合は常に最新化 // _DEV_がtrueの場合は常に最新化
if (!localeRes || _DEV_) { if (!localeRes || _DEV_) {
localeRes = await fetch(localeUrl); localeRes = await fetch(localeUrl);
const clone = localeRes?.clone(); const clone = localeRes.clone();
if (!clone?.clone().ok) Error('locale fetching error'); if (!clone.clone().ok) throw new Error('locale fetching error');
caches.open(this.cacheName).then(cache => cache.put(localeUrl, clone)); caches.open(this.cacheName).then(cache => cache.put(localeUrl, clone));
} }
return new I18n(await localeRes.json()); return new I18n<Locale>(await localeRes.json());
} }
} }

View file

@ -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); const u = new URL(url, origin);
u.searchParams.append('loginId', loginId); u.searchParams.set('loginId', loginId);
return u.toString();
}
export function getUrlWithoutLoginId(url: string) {
const u = new URL(url);
u.searchParams.delete('loginId');
return u.toString(); return u.toString();
} }

View file

@ -3,17 +3,21 @@
* *
*/ */
import * as Misskey from 'misskey-js'; 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 { getAccountFromId } from '@/scripts/get-account-from-id';
import { getUrlWithLoginId } from '@/scripts/login-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']) { 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>>> {
const account = await getAccountFromId(userId); let account: { token: string; id: string } | void;
if (!account) return;
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秒間隔に制限する // mark-all-as-read送出を1秒間隔に制限する
@ -24,55 +28,52 @@ export function sendMarkAllAsRead(userId: string): Promise<null | undefined | vo
return new Promise(resolve => { return new Promise(resolve => {
setTimeout(() => { setTimeout(() => {
readBlockingStatus.set(userId, false); readBlockingStatus.set(userId, false);
api('notifications/mark-all-as-read', userId) api('notifications/mark-all-as-read', userId).then(resolve, resolve);
.then(resolve, resolve);
}, 1000); }, 1000);
}); });
} }
// rendered acctからユーザーを開く // 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 }); return openClient('push', `/@${acct}`, loginId, { acct });
} }
// noteIdからートを開く // 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 }); return openClient('push', `/notes/${noteId}`, loginId, { noteId });
} }
// 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 }); return openClient('push', `/timeline/antenna/${antennaId}`, loginId, { antennaId });
} }
// post-formのオプションから投稿フォームを開く // 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?'; const url = '/share';
if (options.initialText) url += `text=${options.initialText}&`; const query = new URLSearchParams();
if (options.reply) url += `replyId=${options.reply.id}&`; if (options.initialText) query.set('text', options.initialText);
if (options.renote) url += `renoteId=${options.renote.id}&`; 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(); const client = await findClient();
if (client) { 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 client;
} }
return globalThis.clients.openWindow(loginId ? getUrlWithLoginId(url, loginId) : url); 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({ const clients = await globalThis.clients.matchAll({
type: 'window', type: 'window',
}); });
for (const c of clients) { return clients.find(c => !(new URL(c.url)).searchParams.has('zen')) ?? null;
if (!(new URL(c.url)).searchParams.has('zen')) return c;
}
return null;
} }

View file

@ -1,12 +1,8 @@
export const twemojiSvgBase = '/twemoji';
export function char2fileName(char: string): string { 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'); 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('-'); return codes.join('-');
} }
export function char2filePath(char: string): string {
return `${twemojiSvgBase}/${char2fileName(char)}.svg`;
}

View file

@ -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}`;
}

View file

@ -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 { createEmptyNotification, createNotification } from '@/scripts/create-notification';
import { swLang } from '@/scripts/lang'; import { swLang } from '@/scripts/lang';
import { PushNotificationDataMap } from '@/types';
import * as swos from '@/scripts/operations'; import * as swos from '@/scripts/operations';
import { acct as getAcct } from '@/filters/user';
import { get } from 'idb-keyval';
globalThis.addEventListener('install', ev => { globalThis.addEventListener('install', () => {
//ev.waitUntil(globalThis.skipWaiting()); // ev.waitUntil(globalThis.skipWaiting());
}); });
globalThis.addEventListener('activate', ev => { globalThis.addEventListener('activate', ev => {
@ -43,7 +43,7 @@ globalThis.addEventListener('push', ev => {
ev.waitUntil(globalThis.clients.matchAll({ ev.waitUntil(globalThis.clients.matchAll({
includeUncontrolled: true, includeUncontrolled: true,
type: 'window', type: 'window',
}).then(async (clients: readonly WindowClient[]) => { }).then(async () => {
const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.data?.json(); const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.data?.json();
switch (data.type) { switch (data.type) {
@ -66,7 +66,7 @@ globalThis.addEventListener('push', ev => {
}); });
globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEventMap['notificationclick']) => { globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEventMap['notificationclick']) => {
ev.waitUntil((async () => { ev.waitUntil((async (): Promise<void> => {
if (_DEV_) { if (_DEV_) {
console.log('notificationclick', ev.action, ev.notification.data); 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 }); if ('userId' in data.body) await swos.api('following/create', loginId, { userId: data.body.userId });
break; break;
case 'showUser': 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; break;
case 'reply': case 'reply':
if ('note' in data.body) client = await swos.openPost({ reply: data.body.note }, loginId); 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) { if ('note' in data.body) {
client = await swos.openNote(data.body.note.id, loginId); client = await swos.openNote(data.body.note.id, loginId);
} else if ('user' in data.body) { } 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; break;
} }
@ -160,7 +160,7 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv
globalThis.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEventMap['notificationclose']) => { globalThis.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEventMap['notificationclose']) => {
const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.notification.data; const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.notification.data;
ev.waitUntil((async () => { ev.waitUntil((async (): Promise<void> => {
if (data.type === 'notification') { if (data.type === 'notification') {
await swos.sendMarkAllAsRead(data.userId); await swos.sendMarkAllAsRead(data.userId);
} }
@ -169,7 +169,7 @@ globalThis.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEv
}); });
globalThis.addEventListener('message', (ev: ServiceWorkerGlobalScopeEventMap['message']) => { globalThis.addEventListener('message', (ev: ServiceWorkerGlobalScopeEventMap['message']) => {
ev.waitUntil((async () => { ev.waitUntil((async (): Promise<void> => {
switch (ev.data) { switch (ev.data) {
case 'clear': case 'clear':
// Cache Storage全削除 // Cache Storage全削除

View file

@ -1,20 +1,20 @@
import * as Misskey from 'misskey-js'; import type * as Misskey from 'misskey-js';
export type SwMessageOrderType = 'post' | 'push'; export type SwMessageOrderType = 'post' | 'push';
export type SwMessage = { export type SwMessage = {
type: 'order'; type: 'order';
order: SwMessageOrderType; order: SwMessageOrderType;
loginId: string; loginId?: string;
url: string; url: string;
[x: string]: any; [x: string]: unknown;
}; };
// Defined also @/core/PushNotificationService.ts#L12 // Defined also @/core/PushNotificationService.ts#L12
type PushNotificationDataSourceMap = { type PushNotificationDataSourceMap = {
notification: Misskey.entities.Notification; notification: Misskey.entities.Notification;
unreadAntennaNote: { unreadAntennaNote: {
antenna: { id: string, name: string }; antenna: { id: string; name: string };
note: Misskey.entities.Note; note: Misskey.entities.Note;
}; };
readAllNotifications: undefined; readAllNotifications: undefined;
@ -31,8 +31,8 @@ export type PushNotificationDataMap = {
[K in keyof PushNotificationDataSourceMap]: PushNotificationData<K>; [K in keyof PushNotificationDataSourceMap]: PushNotificationData<K>;
}; };
export type BadgeNames = export type BadgeNames =
'null' | 'null'
| 'antenna' | 'antenna'
| 'arrow-back-up' | 'arrow-back-up'
| 'at' | 'at'
@ -44,5 +44,4 @@ export type BadgeNames =
| 'quote' | 'quote'
| 'repeat' | 'repeat'
| 'user-plus' | 'user-plus'
| 'users' | 'users';
;