Merge branch 'develop' into complete-emoji-after-last-colon
This commit is contained in:
commit
ba2abb250d
12
CHANGELOG.md
12
CHANGELOG.md
|
@ -4,13 +4,19 @@
|
||||||
-
|
-
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
|
- Feat: チャットウィジェットを追加
|
||||||
|
- Feat: デッキにチャットカラムを追加
|
||||||
- Enhance: Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように
|
- Enhance: Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように
|
||||||
- Fix: ログアウトした際に処理が終了しない問題を修正
|
- Fix: ログアウトした際に処理が終了しない問題を修正
|
||||||
- Fix: 自動バックアップが設定されている環境でログアウト直前に設定をバックアップするように
|
- Fix: 自動バックアップが設定されている環境でログアウト直前に設定をバックアップするように
|
||||||
|
- Fix: フォルダを開いた状態でメニューからアップロードしてもルートフォルダにアップロードされる問題を修正 #15836
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
- Enhance: フォローしているユーザーならフォロワー限定投稿のノートでもアンテナで検知できるように
|
||||||
|
(Cherry-picked from https://github.com/yojo-art/cherrypick/pull/568 and https://github.com/team-shahu/misskey/pull/38)
|
||||||
- Fix: システムアカウントの名前がサーバー名と同期されない問題を修正
|
- Fix: システムアカウントの名前がサーバー名と同期されない問題を修正
|
||||||
|
- Fix: 大文字を含むユーザの URL で紹介された場合に 404 エラーを返す問題 #15813
|
||||||
|
- Fix: リードレプリカ設定時にレコードの追加・更新・削除を伴うクエリを発行した際はmasterノードで実行されるように調整( #10897 )
|
||||||
|
|
||||||
## 2025.4.0
|
## 2025.4.0
|
||||||
|
|
||||||
|
@ -44,7 +50,7 @@
|
||||||
- プラグイン、テーマ、クライアントに追加されたすべてのアカウント情報も含まれるようになりました
|
- プラグイン、テーマ、クライアントに追加されたすべてのアカウント情報も含まれるようになりました
|
||||||
- 自動で設定データをサーバーにバックアップできるように
|
- 自動で設定データをサーバーにバックアップできるように
|
||||||
- 設定→設定のプロファイル→自動バックアップ で有効にできます
|
- 設定→設定のプロファイル→自動バックアップ で有効にできます
|
||||||
- 新しいデバイスからログインしたり、ブラウザから設定データが消えてしまったときに自動で復元されます(復元をスキップすることも可能)
|
- ログインしたとき、ブラウザから設定データが消えてしまったときに自動で復元されます(復元をスキップすることも可能)
|
||||||
- 任意の設定項目をデバイス間で同期できるように
|
- 任意の設定項目をデバイス間で同期できるように
|
||||||
- 設定項目の「...」メニュー→「デバイス間で同期」
|
- 設定項目の「...」メニュー→「デバイス間で同期」
|
||||||
- 同期をオンにした際にサーバーに保存された値とローカルの値が競合する場合はどちらを優先するか選択できます
|
- 同期をオンにした際にサーバーに保存された値とローカルの値が競合する場合はどちらを優先するか選択できます
|
||||||
|
@ -53,7 +59,7 @@
|
||||||
- アカウントごとに設定値が分離される設定とそうでないクライアント設定が混在していた(かつ分離するかどうかを設定不可だった)のを、基本的に一律でクライアント全体に適用されるようにし、個別でアカウントごとに異なる設定を行えるように
|
- アカウントごとに設定値が分離される設定とそうでないクライアント設定が混在していた(かつ分離するかどうかを設定不可だった)のを、基本的に一律でクライアント全体に適用されるようにし、個別でアカウントごとに異なる設定を行えるように
|
||||||
- 設定項目の「...」メニュー→「アカウントで上書き」をオンにすることで、設定値をそのアカウントでだけ適用するようにできます
|
- 設定項目の「...」メニュー→「アカウントで上書き」をオンにすることで、設定値をそのアカウントでだけ適用するようにできます
|
||||||
- ログアウトすると設定データもブラウザから消去されるようになりプライバシーが向上しました
|
- ログアウトすると設定データもブラウザから消去されるようになりプライバシーが向上しました
|
||||||
- 再度ログインすればサーバーのバックアップから設定データを復元可能です
|
- バックアップを有効にしている場合、ログインした後にバックアップから設定データを復元可能です
|
||||||
- エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました
|
- エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました
|
||||||
- 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です
|
- 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です
|
||||||
- 過去に作成されたバックアップデータとは現在互換性がありませんのでご注意ください
|
- 過去に作成されたバックアップデータとは現在互換性がありませんのでご注意ください
|
||||||
|
|
|
@ -9207,6 +9207,10 @@ export interface Locale extends ILocale {
|
||||||
* 今日誕生日のユーザー
|
* 今日誕生日のユーザー
|
||||||
*/
|
*/
|
||||||
"birthdayFollowings": string;
|
"birthdayFollowings": string;
|
||||||
|
/**
|
||||||
|
* チャット
|
||||||
|
*/
|
||||||
|
"chat": string;
|
||||||
};
|
};
|
||||||
"_cw": {
|
"_cw": {
|
||||||
/**
|
/**
|
||||||
|
@ -10230,6 +10234,10 @@ export interface Locale extends ILocale {
|
||||||
* ロールタイムライン
|
* ロールタイムライン
|
||||||
*/
|
*/
|
||||||
"roleTimeline": string;
|
"roleTimeline": string;
|
||||||
|
/**
|
||||||
|
* チャット
|
||||||
|
*/
|
||||||
|
"chat": string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
"_dialog": {
|
"_dialog": {
|
||||||
|
|
|
@ -2421,6 +2421,7 @@ _widgets:
|
||||||
chooseList: "リストを選択"
|
chooseList: "リストを選択"
|
||||||
clicker: "クリッカー"
|
clicker: "クリッカー"
|
||||||
birthdayFollowings: "今日誕生日のユーザー"
|
birthdayFollowings: "今日誕生日のユーザー"
|
||||||
|
chat: "チャット"
|
||||||
|
|
||||||
_cw:
|
_cw:
|
||||||
hide: "隠す"
|
hide: "隠す"
|
||||||
|
@ -2705,6 +2706,7 @@ _deck:
|
||||||
mentions: "あなた宛て"
|
mentions: "あなた宛て"
|
||||||
direct: "ダイレクト"
|
direct: "ダイレクト"
|
||||||
roleTimeline: "ロールタイムライン"
|
roleTimeline: "ロールタイムライン"
|
||||||
|
chat: "チャット"
|
||||||
|
|
||||||
_dialog:
|
_dialog:
|
||||||
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
|
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
|
||||||
|
|
|
@ -5,18 +5,19 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
|
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||||
|
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||||
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import * as Acct from '@/misc/acct.js';
|
||||||
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
|
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
|
||||||
import type { MiAntenna } from '@/models/Antenna.js';
|
import type { MiAntenna } from '@/models/Antenna.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { CacheService } from './CacheService.js';
|
||||||
import * as Acct from '@/misc/acct.js';
|
|
||||||
import type { Packed } from '@/misc/json-schema.js';
|
|
||||||
import { DI } from '@/di-symbols.js';
|
|
||||||
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
|
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
|
||||||
import { bindThis } from '@/decorators.js';
|
|
||||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
|
||||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
|
||||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -37,6 +38,7 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
@Inject(DI.userListMembershipsRepository)
|
@Inject(DI.userListMembershipsRepository)
|
||||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||||
|
|
||||||
|
private cacheService: CacheService,
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private fanoutTimelineService: FanoutTimelineService,
|
private fanoutTimelineService: FanoutTimelineService,
|
||||||
|
@ -111,9 +113,6 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> {
|
public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> {
|
||||||
if (note.visibility === 'specified') return false;
|
|
||||||
if (note.visibility === 'followers') return false;
|
|
||||||
|
|
||||||
if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false;
|
if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false;
|
||||||
|
|
||||||
if (antenna.excludeBots && noteUser.isBot) return false;
|
if (antenna.excludeBots && noteUser.isBot) return false;
|
||||||
|
@ -122,6 +121,18 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
|
|
||||||
if (!antenna.withReplies && note.replyId != null) return false;
|
if (!antenna.withReplies && note.replyId != null) return false;
|
||||||
|
|
||||||
|
if (note.visibility === 'specified') {
|
||||||
|
if (note.userId !== antenna.userId) {
|
||||||
|
if (note.visibleUserIds == null) return false;
|
||||||
|
if (!note.visibleUserIds.includes(antenna.userId)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.visibility === 'followers') {
|
||||||
|
const isFollowing = Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(antenna.userId), note.userId);
|
||||||
|
if (!isFollowing && antenna.userId !== note.userId) return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (antenna.src === 'home') {
|
if (antenna.src === 'home') {
|
||||||
// TODO
|
// TODO
|
||||||
} else if (antenna.src === 'list') {
|
} else if (antenna.src === 'list') {
|
||||||
|
|
|
@ -54,7 +54,7 @@ export class FanoutTimelineEndpointService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
|
async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
|
||||||
// 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える
|
// 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える
|
||||||
if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]);
|
if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]);
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import * as parse5 from 'parse5';
|
import * as parse5 from 'parse5';
|
||||||
import { Window, XMLSerializer } from 'happy-dom';
|
import { type Document, type HTMLParagraphElement, Window, XMLSerializer } from 'happy-dom';
|
||||||
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 { intersperse } from '@/misc/prelude/array.js';
|
import { intersperse } from '@/misc/prelude/array.js';
|
||||||
|
@ -23,6 +23,8 @@ type ChildNode = DefaultTreeAdapterMap['childNode'];
|
||||||
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
|
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
|
||||||
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
|
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
|
||||||
|
|
||||||
|
export type Appender = (document: Document, body: HTMLParagraphElement) => void;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MfmService {
|
export class MfmService {
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -267,7 +269,7 @@ export class MfmService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
|
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
|
||||||
if (nodes == null) {
|
if (nodes == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -492,6 +494,10 @@ export class MfmService {
|
||||||
|
|
||||||
appendChildren(nodes, body);
|
appendChildren(nodes, body);
|
||||||
|
|
||||||
|
for (const additionalAppender of additionalAppenders) {
|
||||||
|
additionalAppender(doc, body);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove the unnecessary namespace
|
// Remove the unnecessary namespace
|
||||||
const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*<p xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">/, '<p>');
|
const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*<p xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">/, '<p>');
|
||||||
|
|
||||||
|
|
|
@ -411,8 +411,8 @@ export class WebhookTestService {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
host: user.host,
|
host: user.host,
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarId == null ? null : user.avatarUrl,
|
||||||
avatarBlurhash: user.avatarBlurhash,
|
avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
|
||||||
avatarDecorations: user.avatarDecorations.map(it => ({
|
avatarDecorations: user.avatarDecorations.map(it => ({
|
||||||
id: it.id,
|
id: it.id,
|
||||||
angle: it.angle,
|
angle: it.angle,
|
||||||
|
@ -441,8 +441,8 @@ export class WebhookTestService {
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: user.updatedAt?.toISOString() ?? null,
|
updatedAt: user.updatedAt?.toISOString() ?? null,
|
||||||
lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
|
lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
|
||||||
bannerUrl: user.bannerUrl,
|
bannerUrl: user.bannerId == null ? null : user.bannerUrl,
|
||||||
bannerBlurhash: user.bannerBlurhash,
|
bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
|
||||||
isLocked: user.isLocked,
|
isLocked: user.isLocked,
|
||||||
isSilenced: false,
|
isSilenced: false,
|
||||||
isSuspended: user.isSuspended,
|
isSuspended: user.isSuspended,
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import * as mfm from 'mfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { MfmService } from '@/core/MfmService.js';
|
import { MfmService, Appender } from '@/core/MfmService.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { extractApHashtagObjects } from './models/tag.js';
|
import { extractApHashtagObjects } from './models/tag.js';
|
||||||
|
@ -25,17 +25,17 @@ export class ApMfmService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, apAppend?: string) {
|
public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, additionalAppender: Appender[] = []) {
|
||||||
let noMisskeyContent = false;
|
let noMisskeyContent = false;
|
||||||
const srcMfm = (note.text ?? '') + (apAppend ?? '');
|
const srcMfm = (note.text ?? '');
|
||||||
|
|
||||||
const parsed = mfm.parse(srcMfm);
|
const parsed = mfm.parse(srcMfm);
|
||||||
|
|
||||||
if (!apAppend && parsed?.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
|
if (!additionalAppender.length && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
|
||||||
noMisskeyContent = true;
|
noMisskeyContent = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers));
|
const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), additionalAppender);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content,
|
content,
|
||||||
|
|
|
@ -19,7 +19,7 @@ import type { MiEmoji } from '@/models/Emoji.js';
|
||||||
import type { MiPoll } from '@/models/Poll.js';
|
import type { MiPoll } from '@/models/Poll.js';
|
||||||
import type { MiPollVote } from '@/models/PollVote.js';
|
import type { MiPollVote } from '@/models/PollVote.js';
|
||||||
import { UserKeypairService } from '@/core/UserKeypairService.js';
|
import { UserKeypairService } from '@/core/UserKeypairService.js';
|
||||||
import { MfmService } from '@/core/MfmService.js';
|
import { MfmService, type Appender } from '@/core/MfmService.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||||
import type { MiUserKeypair } from '@/models/UserKeypair.js';
|
import type { MiUserKeypair } from '@/models/UserKeypair.js';
|
||||||
|
@ -430,10 +430,24 @@ export class ApRendererService {
|
||||||
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
let apAppend = '';
|
const apAppend: Appender[] = [];
|
||||||
|
|
||||||
if (quote) {
|
if (quote) {
|
||||||
apAppend += `\n\nRE: ${quote}`;
|
// Append quote link as `<br><br><span class="quote-inline">RE: <a href="...">...</a></span>`
|
||||||
|
// the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
|
||||||
|
// For compatibility, the span part should be kept as possible.
|
||||||
|
apAppend.push((doc, body) => {
|
||||||
|
body.appendChild(doc.createElement('br'));
|
||||||
|
body.appendChild(doc.createElement('br'));
|
||||||
|
const span = doc.createElement('span');
|
||||||
|
span.className = 'quote-inline';
|
||||||
|
span.appendChild(doc.createTextNode('RE: '));
|
||||||
|
const link = doc.createElement('a');
|
||||||
|
link.setAttribute('href', quote);
|
||||||
|
link.textContent = quote;
|
||||||
|
span.appendChild(link);
|
||||||
|
body.appendChild(span);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
|
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
|
||||||
|
|
|
@ -486,8 +486,8 @@ export class UserEntityService implements OnModuleInit {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
host: user.host,
|
host: user.host,
|
||||||
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
|
avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? this.getIdenticonUrl(user),
|
||||||
avatarBlurhash: user.avatarBlurhash,
|
avatarBlurhash: (user.avatarId == null ? null : user.avatarBlurhash),
|
||||||
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({
|
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({
|
||||||
id: ud.id,
|
id: ud.id,
|
||||||
angle: ud.angle || undefined,
|
angle: ud.angle || undefined,
|
||||||
|
@ -533,8 +533,8 @@ export class UserEntityService implements OnModuleInit {
|
||||||
createdAt: this.idService.parse(user.id).date.toISOString(),
|
createdAt: this.idService.parse(user.id).date.toISOString(),
|
||||||
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
|
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
|
||||||
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
|
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
|
||||||
bannerUrl: user.bannerUrl,
|
bannerUrl: user.bannerId == null ? null : user.bannerUrl,
|
||||||
bannerBlurhash: user.bannerBlurhash,
|
bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
|
||||||
isLocked: user.isLocked,
|
isLocked: user.isLocked,
|
||||||
isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
|
isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
|
||||||
isSuspended: user.isSuspended,
|
isSuspended: user.isSuspended,
|
||||||
|
|
|
@ -118,21 +118,25 @@ export class MiUser {
|
||||||
@JoinColumn()
|
@JoinColumn()
|
||||||
public banner: MiDriveFile | null;
|
public banner: MiDriveFile | null;
|
||||||
|
|
||||||
|
// avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 512, nullable: true,
|
length: 512, nullable: true,
|
||||||
})
|
})
|
||||||
public avatarUrl: string | null;
|
public avatarUrl: string | null;
|
||||||
|
|
||||||
|
// bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 512, nullable: true,
|
length: 512, nullable: true,
|
||||||
})
|
})
|
||||||
public bannerUrl: string | null;
|
public bannerUrl: string | null;
|
||||||
|
|
||||||
|
// avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 128, nullable: true,
|
length: 128, nullable: true,
|
||||||
})
|
})
|
||||||
public avatarBlurhash: string | null;
|
public avatarBlurhash: string | null;
|
||||||
|
|
||||||
|
// bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 128, nullable: true,
|
length: 128, nullable: true,
|
||||||
})
|
})
|
||||||
|
|
|
@ -3,29 +3,48 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm';
|
import {
|
||||||
|
FindOneOptions,
|
||||||
|
InsertQueryBuilder,
|
||||||
|
ObjectLiteral,
|
||||||
|
QueryRunner,
|
||||||
|
Repository,
|
||||||
|
SelectQueryBuilder,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
|
||||||
import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js';
|
import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js';
|
||||||
import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js';
|
import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js';
|
||||||
import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
|
import {
|
||||||
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
RawSqlResultsToEntityTransformer,
|
||||||
|
} from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
|
||||||
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
|
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
|
||||||
|
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
||||||
import { MiAccessToken } from '@/models/AccessToken.js';
|
import { MiAccessToken } from '@/models/AccessToken.js';
|
||||||
import { MiAd } from '@/models/Ad.js';
|
import { MiAd } from '@/models/Ad.js';
|
||||||
import { MiAnnouncement } from '@/models/Announcement.js';
|
import { MiAnnouncement } from '@/models/Announcement.js';
|
||||||
import { MiAnnouncementRead } from '@/models/AnnouncementRead.js';
|
import { MiAnnouncementRead } from '@/models/AnnouncementRead.js';
|
||||||
import { MiAntenna } from '@/models/Antenna.js';
|
import { MiAntenna } from '@/models/Antenna.js';
|
||||||
import { MiApp } from '@/models/App.js';
|
import { MiApp } from '@/models/App.js';
|
||||||
import { MiAvatarDecoration } from '@/models/AvatarDecoration.js';
|
|
||||||
import { MiAuthSession } from '@/models/AuthSession.js';
|
import { MiAuthSession } from '@/models/AuthSession.js';
|
||||||
|
import { MiAvatarDecoration } from '@/models/AvatarDecoration.js';
|
||||||
import { MiBlocking } from '@/models/Blocking.js';
|
import { MiBlocking } from '@/models/Blocking.js';
|
||||||
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
|
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
||||||
|
import { MiChannel } from '@/models/Channel.js';
|
||||||
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
|
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
|
||||||
|
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
|
||||||
|
import { MiChatApproval } from '@/models/ChatApproval.js';
|
||||||
|
import { MiChatMessage } from '@/models/ChatMessage.js';
|
||||||
|
import { MiChatRoom } from '@/models/ChatRoom.js';
|
||||||
|
import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
|
||||||
|
import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
|
||||||
import { MiClip } from '@/models/Clip.js';
|
import { MiClip } from '@/models/Clip.js';
|
||||||
import { MiClipNote } from '@/models/ClipNote.js';
|
|
||||||
import { MiClipFavorite } from '@/models/ClipFavorite.js';
|
import { MiClipFavorite } from '@/models/ClipFavorite.js';
|
||||||
|
import { MiClipNote } from '@/models/ClipNote.js';
|
||||||
import { MiDriveFile } from '@/models/DriveFile.js';
|
import { MiDriveFile } from '@/models/DriveFile.js';
|
||||||
import { MiDriveFolder } from '@/models/DriveFolder.js';
|
import { MiDriveFolder } from '@/models/DriveFolder.js';
|
||||||
import { MiEmoji } from '@/models/Emoji.js';
|
import { MiEmoji } from '@/models/Emoji.js';
|
||||||
|
import { MiFlash } from '@/models/Flash.js';
|
||||||
|
import { MiFlashLike } from '@/models/FlashLike.js';
|
||||||
import { MiFollowing } from '@/models/Following.js';
|
import { MiFollowing } from '@/models/Following.js';
|
||||||
import { MiFollowRequest } from '@/models/FollowRequest.js';
|
import { MiFollowRequest } from '@/models/FollowRequest.js';
|
||||||
import { MiGalleryLike } from '@/models/GalleryLike.js';
|
import { MiGalleryLike } from '@/models/GalleryLike.js';
|
||||||
|
@ -35,7 +54,6 @@ import { MiInstance } from '@/models/Instance.js';
|
||||||
import { MiMeta } from '@/models/Meta.js';
|
import { MiMeta } from '@/models/Meta.js';
|
||||||
import { MiModerationLog } from '@/models/ModerationLog.js';
|
import { MiModerationLog } from '@/models/ModerationLog.js';
|
||||||
import { MiMuting } from '@/models/Muting.js';
|
import { MiMuting } from '@/models/Muting.js';
|
||||||
import { MiRenoteMuting } from '@/models/RenoteMuting.js';
|
|
||||||
import { MiNote } from '@/models/Note.js';
|
import { MiNote } from '@/models/Note.js';
|
||||||
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
|
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
|
||||||
import { MiNoteReaction } from '@/models/NoteReaction.js';
|
import { MiNoteReaction } from '@/models/NoteReaction.js';
|
||||||
|
@ -50,42 +68,38 @@ import { MiPromoRead } from '@/models/PromoRead.js';
|
||||||
import { MiRegistrationTicket } from '@/models/RegistrationTicket.js';
|
import { MiRegistrationTicket } from '@/models/RegistrationTicket.js';
|
||||||
import { MiRegistryItem } from '@/models/RegistryItem.js';
|
import { MiRegistryItem } from '@/models/RegistryItem.js';
|
||||||
import { MiRelay } from '@/models/Relay.js';
|
import { MiRelay } from '@/models/Relay.js';
|
||||||
|
import { MiRenoteMuting } from '@/models/RenoteMuting.js';
|
||||||
|
import { MiRetentionAggregation } from '@/models/RetentionAggregation.js';
|
||||||
|
import { MiReversiGame } from '@/models/ReversiGame.js';
|
||||||
|
import { MiRole } from '@/models/Role.js';
|
||||||
|
import { MiRoleAssignment } from '@/models/RoleAssignment.js';
|
||||||
import { MiSignin } from '@/models/Signin.js';
|
import { MiSignin } from '@/models/Signin.js';
|
||||||
import { MiSwSubscription } from '@/models/SwSubscription.js';
|
import { MiSwSubscription } from '@/models/SwSubscription.js';
|
||||||
import { MiSystemAccount } from '@/models/SystemAccount.js';
|
import { MiSystemAccount } from '@/models/SystemAccount.js';
|
||||||
|
import { MiSystemWebhook } from '@/models/SystemWebhook.js';
|
||||||
import { MiUsedUsername } from '@/models/UsedUsername.js';
|
import { MiUsedUsername } from '@/models/UsedUsername.js';
|
||||||
import { MiUser } from '@/models/User.js';
|
import { MiUser } from '@/models/User.js';
|
||||||
import { MiUserIp } from '@/models/UserIp.js';
|
import { MiUserIp } from '@/models/UserIp.js';
|
||||||
import { MiUserKeypair } from '@/models/UserKeypair.js';
|
import { MiUserKeypair } from '@/models/UserKeypair.js';
|
||||||
import { MiUserList } from '@/models/UserList.js';
|
import { MiUserList } from '@/models/UserList.js';
|
||||||
|
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
|
||||||
import { MiUserListMembership } from '@/models/UserListMembership.js';
|
import { MiUserListMembership } from '@/models/UserListMembership.js';
|
||||||
|
import { MiUserMemo } from '@/models/UserMemo.js';
|
||||||
import { MiUserNotePining } from '@/models/UserNotePining.js';
|
import { MiUserNotePining } from '@/models/UserNotePining.js';
|
||||||
import { MiUserPending } from '@/models/UserPending.js';
|
import { MiUserPending } from '@/models/UserPending.js';
|
||||||
import { MiUserProfile } from '@/models/UserProfile.js';
|
import { MiUserProfile } from '@/models/UserProfile.js';
|
||||||
import { MiUserPublickey } from '@/models/UserPublickey.js';
|
import { MiUserPublickey } from '@/models/UserPublickey.js';
|
||||||
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
|
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
|
||||||
import { MiUserMemo } from '@/models/UserMemo.js';
|
|
||||||
import { MiWebhook } from '@/models/Webhook.js';
|
import { MiWebhook } from '@/models/Webhook.js';
|
||||||
import { MiSystemWebhook } from '@/models/SystemWebhook.js';
|
|
||||||
import { MiChannel } from '@/models/Channel.js';
|
|
||||||
import { MiRetentionAggregation } from '@/models/RetentionAggregation.js';
|
|
||||||
import { MiRole } from '@/models/Role.js';
|
|
||||||
import { MiRoleAssignment } from '@/models/RoleAssignment.js';
|
|
||||||
import { MiFlash } from '@/models/Flash.js';
|
|
||||||
import { MiFlashLike } from '@/models/FlashLike.js';
|
|
||||||
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
|
|
||||||
import { MiChatMessage } from '@/models/ChatMessage.js';
|
|
||||||
import { MiChatRoom } from '@/models/ChatRoom.js';
|
|
||||||
import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
|
|
||||||
import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
|
|
||||||
import { MiChatApproval } from '@/models/ChatApproval.js';
|
|
||||||
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
|
||||||
import { MiReversiGame } from '@/models/ReversiGame.js';
|
|
||||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
|
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
|
||||||
|
|
||||||
export interface MiRepository<T extends ObjectLiteral> {
|
export interface MiRepository<T extends ObjectLiteral> {
|
||||||
createTableColumnNames(this: Repository<T> & MiRepository<T>): string[];
|
createTableColumnNames(this: Repository<T> & MiRepository<T>): string[];
|
||||||
|
|
||||||
insertOne(this: Repository<T> & MiRepository<T>, entity: QueryDeepPartialEntity<T>, findOptions?: Pick<FindOneOptions<T>, 'relations'>): Promise<T>;
|
insertOne(this: Repository<T> & MiRepository<T>, entity: QueryDeepPartialEntity<T>, findOptions?: Pick<FindOneOptions<T>, 'relations'>): Promise<T>;
|
||||||
|
|
||||||
|
insertOneImpl(this: Repository<T> & MiRepository<T>, entity: QueryDeepPartialEntity<T>, findOptions?: Pick<FindOneOptions<T>, 'relations'>, queryRunner?: QueryRunner): Promise<T>;
|
||||||
|
|
||||||
selectAliasColumnNames(this: Repository<T> & MiRepository<T>, queryBuilder: InsertQueryBuilder<T>, builder: SelectQueryBuilder<T>): void;
|
selectAliasColumnNames(this: Repository<T> & MiRepository<T>, queryBuilder: InsertQueryBuilder<T>, builder: SelectQueryBuilder<T>): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,6 +108,21 @@ export const miRepository = {
|
||||||
return this.metadata.columns.filter(column => column.isSelect && !column.isVirtual).map(column => column.databaseName);
|
return this.metadata.columns.filter(column => column.isSelect && !column.isVirtual).map(column => column.databaseName);
|
||||||
},
|
},
|
||||||
async insertOne(entity, findOptions?) {
|
async insertOne(entity, findOptions?) {
|
||||||
|
const opt = this.manager.connection.options as PostgresConnectionOptions;
|
||||||
|
if (opt.replication) {
|
||||||
|
const queryRunner = this.manager.connection.createQueryRunner('master');
|
||||||
|
try {
|
||||||
|
return this.insertOneImpl(entity, findOptions, queryRunner);
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return this.insertOneImpl(entity, findOptions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async insertOneImpl(entity, findOptions?, queryRunner?) {
|
||||||
|
// ---- insert + returningの結果を共通テーブル式(CTE)に保持するクエリを生成 ----
|
||||||
|
|
||||||
const queryBuilder = this.createQueryBuilder().insert().values(entity);
|
const queryBuilder = this.createQueryBuilder().insert().values(entity);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const mainAlias = queryBuilder.expressionMap.mainAlias!;
|
const mainAlias = queryBuilder.expressionMap.mainAlias!;
|
||||||
|
@ -101,7 +130,9 @@ export const miRepository = {
|
||||||
mainAlias.name = 't';
|
mainAlias.name = 't';
|
||||||
const columnNames = this.createTableColumnNames();
|
const columnNames = this.createTableColumnNames();
|
||||||
queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2));
|
queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2));
|
||||||
const builder = this.createQueryBuilder().addCommonTableExpression(queryBuilder, 'cte', { columnNames });
|
|
||||||
|
// ---- 共通テーブル式(CTE)から結果を取得 ----
|
||||||
|
const builder = this.createQueryBuilder(undefined, queryRunner).addCommonTableExpression(queryBuilder, 'cte', { columnNames });
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
builder.expressionMap.mainAlias!.tablePath = 'cte';
|
builder.expressionMap.mainAlias!.tablePath = 'cte';
|
||||||
this.selectAliasColumnNames(queryBuilder, builder);
|
this.selectAliasColumnNames(queryBuilder, builder);
|
||||||
|
@ -204,7 +235,9 @@ export {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
|
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
|
||||||
export type AbuseReportNotificationRecipientRepository = Repository<MiAbuseReportNotificationRecipient> & MiRepository<MiAbuseReportNotificationRecipient>;
|
export type AbuseReportNotificationRecipientRepository =
|
||||||
|
Repository<MiAbuseReportNotificationRecipient>
|
||||||
|
& MiRepository<MiAbuseReportNotificationRecipient>;
|
||||||
export type AccessTokensRepository = Repository<MiAccessToken> & MiRepository<MiAccessToken>;
|
export type AccessTokensRepository = Repository<MiAccessToken> & MiRepository<MiAccessToken>;
|
||||||
export type AdsRepository = Repository<MiAd> & MiRepository<MiAd>;
|
export type AdsRepository = Repository<MiAd> & MiRepository<MiAd>;
|
||||||
export type AnnouncementsRepository = Repository<MiAnnouncement> & MiRepository<MiAnnouncement>;
|
export type AnnouncementsRepository = Repository<MiAnnouncement> & MiRepository<MiAnnouncement>;
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
// https://github.com/typeorm/typeorm/issues/2400
|
// https://github.com/typeorm/typeorm/issues/2400
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { DataSource, Logger } from 'typeorm';
|
import { DataSource, Logger, type QueryRunner } from 'typeorm';
|
||||||
import * as highlight from 'cli-highlight';
|
import * as highlight from 'cli-highlight';
|
||||||
import { entities as charts } from '@/core/chart/entities.js';
|
import { entities as charts } from '@/core/chart/entities.js';
|
||||||
import { Config } from '@/config.js';
|
import { Config } from '@/config.js';
|
||||||
|
@ -96,6 +96,7 @@ const sqlLogger = dbLogger.createSubLogger('sql', 'gray');
|
||||||
export type LoggerProps = {
|
export type LoggerProps = {
|
||||||
disableQueryTruncation?: boolean;
|
disableQueryTruncation?: boolean;
|
||||||
enableQueryParamLogging?: boolean;
|
enableQueryParamLogging?: boolean;
|
||||||
|
printReplicationMode?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
function highlightSql(sql: string) {
|
function highlightSql(sql: string) {
|
||||||
|
@ -121,8 +122,10 @@ class MyCustomLogger implements Logger {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private transformQueryLog(sql: string) {
|
private transformQueryLog(sql: string, opts?: {
|
||||||
let modded = sql;
|
prefix?: string;
|
||||||
|
}) {
|
||||||
|
let modded = opts?.prefix ? opts.prefix + sql : sql;
|
||||||
if (!this.props.disableQueryTruncation) {
|
if (!this.props.disableQueryTruncation) {
|
||||||
modded = truncateSql(modded);
|
modded = truncateSql(modded);
|
||||||
}
|
}
|
||||||
|
@ -140,18 +143,27 @@ class MyCustomLogger implements Logger {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public logQuery(query: string, parameters?: any[]) {
|
public logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) {
|
||||||
sqlLogger.info(this.transformQueryLog(query), this.transformParameters(parameters));
|
const prefix = (this.props.printReplicationMode && queryRunner)
|
||||||
|
? `[${queryRunner.getReplicationMode()}] `
|
||||||
|
: undefined;
|
||||||
|
sqlLogger.info(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public logQueryError(error: string, query: string, parameters?: any[]) {
|
public logQueryError(error: string, query: string, parameters?: any[], queryRunner?: QueryRunner) {
|
||||||
sqlLogger.error(this.transformQueryLog(query), this.transformParameters(parameters));
|
const prefix = (this.props.printReplicationMode && queryRunner)
|
||||||
|
? `[${queryRunner.getReplicationMode()}] `
|
||||||
|
: undefined;
|
||||||
|
sqlLogger.error(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public logQuerySlow(time: number, query: string, parameters?: any[]) {
|
public logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) {
|
||||||
sqlLogger.warn(this.transformQueryLog(query), this.transformParameters(parameters));
|
const prefix = (this.props.printReplicationMode && queryRunner)
|
||||||
|
? `[${queryRunner.getReplicationMode()}] `
|
||||||
|
: undefined;
|
||||||
|
sqlLogger.warn(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -298,6 +310,7 @@ export function createPostgresDataSource(config: Config) {
|
||||||
? new MyCustomLogger({
|
? new MyCustomLogger({
|
||||||
disableQueryTruncation: config.logging?.sql?.disableQueryTruncation,
|
disableQueryTruncation: config.logging?.sql?.disableQueryTruncation,
|
||||||
enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging,
|
enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging,
|
||||||
|
printReplicationMode: !!config.dbReplications,
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
maxQueryExecutionTime: 300,
|
maxQueryExecutionTime: 300,
|
||||||
|
|
|
@ -32,6 +32,7 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||||
import * as Acct from '@/misc/acct.js';
|
import * as Acct from '@/misc/acct.js';
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
|
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
|
||||||
import type { FindOptionsWhere } from 'typeorm';
|
import type { FindOptionsWhere } from 'typeorm';
|
||||||
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
|
|
||||||
const ACTIVITY_JSON = 'application/activity+json; charset=utf-8';
|
const ACTIVITY_JSON = 'application/activity+json; charset=utf-8';
|
||||||
const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8';
|
const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8';
|
||||||
|
@ -75,6 +76,7 @@ export class ActivityPubServerService {
|
||||||
private queueService: QueueService,
|
private queueService: QueueService,
|
||||||
private userKeypairService: UserKeypairService,
|
private userKeypairService: UserKeypairService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
) {
|
) {
|
||||||
//this.createServer = this.createServer.bind(this);
|
//this.createServer = this.createServer.bind(this);
|
||||||
}
|
}
|
||||||
|
@ -461,16 +463,28 @@ export class ActivityPubServerService {
|
||||||
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 notes = this.meta.enableFanoutTimeline ? await this.fanoutTimelineEndpointService.getMiNotes({
|
||||||
.andWhere('note.userId = :userId', { userId: user.id })
|
sinceId: sinceId ?? null,
|
||||||
.andWhere(new Brackets(qb => {
|
untilId: untilId ?? null,
|
||||||
qb
|
limit: limit,
|
||||||
.where('note.visibility = \'public\'')
|
allowPartial: false, // Possibly true? IDK it's OK for ordered collection.
|
||||||
.orWhere('note.visibility = \'home\'');
|
me: null,
|
||||||
}))
|
redisTimelines: [
|
||||||
.andWhere('note.localOnly = FALSE');
|
`userTimeline:${user.id}`,
|
||||||
|
`userTimelineWithReplies:${user.id}`,
|
||||||
const notes = await query.limit(limit).getMany();
|
],
|
||||||
|
useDbFallback: true,
|
||||||
|
ignoreAuthorFromMute: true,
|
||||||
|
excludePureRenotes: false,
|
||||||
|
noteFilter: (note) => {
|
||||||
|
if (note.visibility !== 'home' && note.visibility !== 'public') return false;
|
||||||
|
if (note.localOnly) return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
dbFallback: async (untilId, sinceId, limit) => {
|
||||||
|
return await this.getUserNotesFromDb(sinceId, untilId, limit, user.id);
|
||||||
|
},
|
||||||
|
}) : await this.getUserNotesFromDb(sinceId ?? null, untilId ?? null, limit, user.id);
|
||||||
|
|
||||||
if (sinceId) notes.reverse();
|
if (sinceId) notes.reverse();
|
||||||
|
|
||||||
|
@ -508,6 +522,20 @@ export class ActivityPubServerService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async getUserNotesFromDb(untilId: string | null, sinceId: string | null, limit: number, userId: MiUser['id']) {
|
||||||
|
return await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
|
||||||
|
.andWhere('note.userId = :userId', { userId })
|
||||||
|
.andWhere(new Brackets(qb => {
|
||||||
|
qb
|
||||||
|
.where('note.visibility = \'public\'')
|
||||||
|
.orWhere('note.visibility = \'home\'');
|
||||||
|
}))
|
||||||
|
.andWhere('note.localOnly = FALSE')
|
||||||
|
.limit(limit)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) {
|
private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) {
|
||||||
if (this.meta.federation === 'none') {
|
if (this.meta.federation === 'none') {
|
||||||
|
@ -735,7 +763,7 @@ export class ActivityPubServerService {
|
||||||
const acct = Acct.parse(request.params.acct);
|
const acct = Acct.parse(request.params.acct);
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({
|
const user = await this.usersRepository.findOneBy({
|
||||||
usernameLower: acct.username,
|
usernameLower: acct.username.toLowerCase(),
|
||||||
host: acct.host ?? IsNull(),
|
host: acct.host ?? IsNull(),
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
});
|
});
|
||||||
|
|
|
@ -221,7 +221,7 @@ export class ServerService implements OnApplicationShutdown {
|
||||||
reply.header('Cache-Control', 'public, max-age=86400');
|
reply.header('Cache-Control', 'public, max-age=86400');
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user));
|
reply.redirect((user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user));
|
||||||
} else {
|
} else {
|
||||||
reply.redirect('/static-assets/user-unknown.png');
|
reply.redirect('/static-assets/user-unknown.png');
|
||||||
}
|
}
|
||||||
|
|
|
@ -138,7 +138,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
|
||||||
|
|
||||||
const fromAcct = (acct: Acct.Acct): FindOptionsWhere<MiUser> | number =>
|
const fromAcct = (acct: Acct.Acct): FindOptionsWhere<MiUser> | number =>
|
||||||
!acct.host || acct.host === this.config.host.toLowerCase() ? {
|
!acct.host || acct.host === this.config.host.toLowerCase() ? {
|
||||||
usernameLower: acct.username,
|
usernameLower: acct.username.toLowerCase(),
|
||||||
host: IsNull(),
|
host: IsNull(),
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
} : 422;
|
} : 422;
|
||||||
|
|
|
@ -534,7 +534,7 @@ export class ClientServerService {
|
||||||
|
|
||||||
return await reply.view('user', {
|
return await reply.view('user', {
|
||||||
user, profile, me,
|
user, profile, me,
|
||||||
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
avatarUrl: _user.avatarUrl,
|
||||||
sub: request.params.sub,
|
sub: request.params.sub,
|
||||||
...await this.generateCommonPugData(this.meta),
|
...await this.generateCommonPugData(this.meta),
|
||||||
clientCtx: htmlSafeJsonStringify({
|
clientCtx: htmlSafeJsonStringify({
|
||||||
|
|
|
@ -65,7 +65,7 @@ export class FeedService {
|
||||||
generator: 'Misskey',
|
generator: 'Misskey',
|
||||||
description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
|
description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
|
||||||
link: author.link,
|
link: author.link,
|
||||||
image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
image: (user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user),
|
||||||
feedLinks: {
|
feedLinks: {
|
||||||
json: `${author.link}.json`,
|
json: `${author.link}.json`,
|
||||||
atom: `${author.link}.atom`,
|
atom: `${author.link}.atom`,
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
failedApiCall,
|
failedApiCall,
|
||||||
|
@ -19,6 +18,7 @@ import {
|
||||||
userList,
|
userList,
|
||||||
} from '../utils.js';
|
} from '../utils.js';
|
||||||
import type * as misskey from 'misskey-js';
|
import type * as misskey from 'misskey-js';
|
||||||
|
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||||
|
|
||||||
const compareBy = <T extends { id: string }>(selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => {
|
const compareBy = <T extends { id: string }>(selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => {
|
||||||
return selector(a).localeCompare(selector(b));
|
return selector(a).localeCompare(selector(b));
|
||||||
|
@ -235,12 +235,12 @@ describe('アンテナ', () => {
|
||||||
await failedApiCall({
|
await failedApiCall({
|
||||||
endpoint: 'antennas/create',
|
endpoint: 'antennas/create',
|
||||||
parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] },
|
parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] },
|
||||||
user: alice
|
user: alice,
|
||||||
}, {
|
}, {
|
||||||
status: 400,
|
status: 400,
|
||||||
code: 'EMPTY_KEYWORD',
|
code: 'EMPTY_KEYWORD',
|
||||||
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a'
|
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a',
|
||||||
})
|
});
|
||||||
});
|
});
|
||||||
//#endregion
|
//#endregion
|
||||||
//#region 更新(antennas/update)
|
//#region 更新(antennas/update)
|
||||||
|
@ -274,12 +274,12 @@ describe('アンテナ', () => {
|
||||||
await failedApiCall({
|
await failedApiCall({
|
||||||
endpoint: 'antennas/update',
|
endpoint: 'antennas/update',
|
||||||
parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] },
|
parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] },
|
||||||
user: alice
|
user: alice,
|
||||||
}, {
|
}, {
|
||||||
status: 400,
|
status: 400,
|
||||||
code: 'EMPTY_KEYWORD',
|
code: 'EMPTY_KEYWORD',
|
||||||
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4'
|
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4',
|
||||||
})
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
//#endregion
|
//#endregion
|
||||||
|
@ -375,14 +375,23 @@ describe('アンテナ', () => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// https://github.com/misskey-dev/misskey/issues/9025
|
label: 'フォロワー限定投稿とDM投稿を含む',
|
||||||
label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。',
|
|
||||||
parameters: () => ({}),
|
parameters: () => ({}),
|
||||||
posts: [
|
posts: [
|
||||||
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true },
|
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true },
|
||||||
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true },
|
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true },
|
||||||
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }) },
|
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }), included: true },
|
||||||
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) },
|
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }), included: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'フォロワー限定投稿とDM投稿を含まない',
|
||||||
|
parameters: () => ({}),
|
||||||
|
posts: [
|
||||||
|
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'public' }), included: true },
|
||||||
|
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'home' }), included: true },
|
||||||
|
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'followers' }) },
|
||||||
|
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [carol.id] }) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -44,7 +44,7 @@ describe('AnnouncementService', () => {
|
||||||
return usersRepository.insert({
|
return usersRepository.insert({
|
||||||
id: genAidx(Date.now()),
|
id: genAidx(Date.now()),
|
||||||
username: un,
|
username: un,
|
||||||
usernameLower: un,
|
usernameLower: un.toLowerCase(),
|
||||||
...data,
|
...data,
|
||||||
})
|
})
|
||||||
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||||
|
|
|
@ -115,7 +115,7 @@ describe('SigninWithPasskeyApiService', () => {
|
||||||
jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify);
|
jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify);
|
||||||
|
|
||||||
const dummyUser = {
|
const dummyUser = {
|
||||||
id: uid, username: uid, usernameLower: uid.toLocaleLowerCase(), uri: null, host: null,
|
id: uid, username: uid, usernameLower: uid.toLowerCase(), uri: null, host: null,
|
||||||
};
|
};
|
||||||
const dummyProfile = {
|
const dummyProfile = {
|
||||||
userId: uid,
|
userId: uid,
|
||||||
|
|
|
@ -74,7 +74,7 @@ describe('UserEntityService', () => {
|
||||||
...userData,
|
...userData,
|
||||||
id: genAidx(Date.now()),
|
id: genAidx(Date.now()),
|
||||||
username: un,
|
username: un,
|
||||||
usernameLower: un,
|
usernameLower: un.toLowerCase(),
|
||||||
})
|
})
|
||||||
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,208 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="history.length > 0" class="_gaps_s">
|
||||||
|
<MkA
|
||||||
|
v-for="item in history"
|
||||||
|
:key="item.id"
|
||||||
|
:class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]"
|
||||||
|
class="_panel"
|
||||||
|
:to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
|
||||||
|
>
|
||||||
|
<MkAvatar v-if="item.message.toRoomId" :class="$style.messageAvatar" :user="item.message.fromUser" indicator :preview="false"/>
|
||||||
|
<MkAvatar v-else-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
|
||||||
|
<div :class="$style.messageBody">
|
||||||
|
<header v-if="item.message.toRoom" :class="$style.messageHeader">
|
||||||
|
<span :class="$style.messageHeaderName"><i class="ti ti-users"></i> {{ item.message.toRoom.name }}</span>
|
||||||
|
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
|
||||||
|
</header>
|
||||||
|
<header v-else :class="$style.messageHeader">
|
||||||
|
<MkUserName :class="$style.messageHeaderName" :user="item.other!"/>
|
||||||
|
<MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/>
|
||||||
|
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
|
||||||
|
</header>
|
||||||
|
<div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div>
|
||||||
|
</div>
|
||||||
|
</MkA>
|
||||||
|
</div>
|
||||||
|
<div v-if="!initializing && history.length == 0" class="_fullinfo">
|
||||||
|
<div>{{ i18n.ts._chat.noHistory }}</div>
|
||||||
|
</div>
|
||||||
|
<MkLoading v-if="initializing"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onActivated, onDeactivated, onMounted, ref } from 'vue';
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
|
import { useInterval } from '@@/js/use-interval.js';
|
||||||
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { ensureSignin } from '@/i.js';
|
||||||
|
|
||||||
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
const history = ref<{
|
||||||
|
id: string;
|
||||||
|
message: Misskey.entities.ChatMessage;
|
||||||
|
other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null;
|
||||||
|
isMe: boolean;
|
||||||
|
}[]>([]);
|
||||||
|
|
||||||
|
const initializing = ref(true);
|
||||||
|
const fetching = ref(false);
|
||||||
|
|
||||||
|
async function fetchHistory() {
|
||||||
|
if (fetching.value) return;
|
||||||
|
|
||||||
|
fetching.value = true;
|
||||||
|
|
||||||
|
const [userMessages, roomMessages] = await Promise.all([
|
||||||
|
misskeyApi('chat/history', { room: false }),
|
||||||
|
misskeyApi('chat/history', { room: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
history.value = [...userMessages, ...roomMessages]
|
||||||
|
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
message: m,
|
||||||
|
other: (!('room' in m) || m.room == null) ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null,
|
||||||
|
isMe: m.fromUserId === $i.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
fetching.value = false;
|
||||||
|
initializing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isActivated = true;
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
isActivated = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
isActivated = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
useInterval(() => {
|
||||||
|
// TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
|
||||||
|
if (!window.document.hidden && isActivated) {
|
||||||
|
fetchHistory();
|
||||||
|
}
|
||||||
|
}, 1000 * 10, {
|
||||||
|
immediate: false,
|
||||||
|
afterMounted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
fetchHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchHistory();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.message {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
padding: 16px 24px;
|
||||||
|
|
||||||
|
&.isRead,
|
||||||
|
&.isMe {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.isMe):not(.isRead) {
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 100%;
|
||||||
|
background-color: var(--MI_THEME-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 500px) {
|
||||||
|
.message {
|
||||||
|
font-size: 90%;
|
||||||
|
padding: 14px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 450px) {
|
||||||
|
.message {
|
||||||
|
font-size: 80%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAvatar {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
margin: 0 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 500px) {
|
||||||
|
.messageAvatar {
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 450px) {
|
||||||
|
.messageAvatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageBody {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageHeaderName {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageHeaderUsername {
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageHeaderTime {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageBodyText {
|
||||||
|
overflow: hidden;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.youSaid {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -626,13 +626,13 @@ function getMenu() {
|
||||||
text: i18n.ts.upload + ' (' + i18n.ts.compress + ')',
|
text: i18n.ts.upload + ' (' + i18n.ts.compress + ')',
|
||||||
icon: 'ti ti-upload',
|
icon: 'ti ti-upload',
|
||||||
action: () => {
|
action: () => {
|
||||||
chooseFileFromPc(true, { keepOriginal: false });
|
chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: false });
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
text: i18n.ts.upload,
|
text: i18n.ts.upload,
|
||||||
icon: 'ti ti-upload',
|
icon: 'ti ti-upload',
|
||||||
action: () => {
|
action: () => {
|
||||||
chooseFileFromPc(true, { keepOriginal: true });
|
chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: true });
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
text: i18n.ts.fromUrl,
|
text: i18n.ts.fromUrl,
|
||||||
|
|
|
@ -38,6 +38,7 @@ export const columnTypes = [
|
||||||
'mentions',
|
'mentions',
|
||||||
'direct',
|
'direct',
|
||||||
'roleTimeline',
|
'roleTimeline',
|
||||||
|
'chat',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ColumnType = typeof columnTypes[number];
|
export type ColumnType = typeof columnTypes[number];
|
||||||
|
|
|
@ -34,34 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkFoldableSection>
|
<MkFoldableSection>
|
||||||
<template #header>{{ i18n.ts._chat.history }}</template>
|
<template #header>{{ i18n.ts._chat.history }}</template>
|
||||||
|
|
||||||
<div v-if="history.length > 0" class="_gaps_s">
|
<MkChatHistories/>
|
||||||
<MkA
|
|
||||||
v-for="item in history"
|
|
||||||
:key="item.id"
|
|
||||||
:class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]"
|
|
||||||
class="_panel"
|
|
||||||
:to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
|
|
||||||
>
|
|
||||||
<MkAvatar v-if="item.message.toRoomId" :class="$style.messageAvatar" :user="item.message.fromUser" indicator :preview="false"/>
|
|
||||||
<MkAvatar v-else-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
|
|
||||||
<div :class="$style.messageBody">
|
|
||||||
<header v-if="item.message.toRoom" :class="$style.messageHeader">
|
|
||||||
<span :class="$style.messageHeaderName"><i class="ti ti-users"></i> {{ item.message.toRoom.name }}</span>
|
|
||||||
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
|
|
||||||
</header>
|
|
||||||
<header v-else :class="$style.messageHeader">
|
|
||||||
<MkUserName :class="$style.messageHeaderName" :user="item.other!"/>
|
|
||||||
<MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/>
|
|
||||||
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
|
|
||||||
</header>
|
|
||||||
<div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div>
|
|
||||||
</div>
|
|
||||||
</MkA>
|
|
||||||
</div>
|
|
||||||
<div v-if="!initializing && history.length == 0" class="_fullinfo">
|
|
||||||
<div>{{ i18n.ts._chat.noHistory }}</div>
|
|
||||||
</div>
|
|
||||||
<MkLoading v-if="initializing"/>
|
|
||||||
</MkFoldableSection>
|
</MkFoldableSection>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -81,20 +54,12 @@ import { updateCurrentAccountPartial } from '@/accounts.js';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
import MkInput from '@/components/MkInput.vue';
|
||||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||||
import MkInfo from '@/components/MkInfo.vue';
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
|
import MkChatHistories from '@/components/MkChatHistories.vue';
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const initializing = ref(true);
|
|
||||||
const fetching = ref(false);
|
|
||||||
const history = ref<{
|
|
||||||
id: string;
|
|
||||||
message: Misskey.entities.ChatMessage;
|
|
||||||
other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null;
|
|
||||||
isMe: boolean;
|
|
||||||
}[]>([]);
|
|
||||||
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const searched = ref(false);
|
const searched = ref(false);
|
||||||
const searchResults = ref<Misskey.entities.ChatMessage[]>([]);
|
const searchResults = ref<Misskey.entities.ChatMessage[]>([]);
|
||||||
|
@ -148,57 +113,8 @@ async function search() {
|
||||||
searched.value = true;
|
searched.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHistory() {
|
|
||||||
if (fetching.value) return;
|
|
||||||
|
|
||||||
fetching.value = true;
|
|
||||||
|
|
||||||
const [userMessages, roomMessages] = await Promise.all([
|
|
||||||
misskeyApi('chat/history', { room: false }),
|
|
||||||
misskeyApi('chat/history', { room: true }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
history.value = [...userMessages, ...roomMessages]
|
|
||||||
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
|
||||||
.map(m => ({
|
|
||||||
id: m.id,
|
|
||||||
message: m,
|
|
||||||
other: (!('room' in m) || m.room == null) ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null,
|
|
||||||
isMe: m.fromUserId === $i.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
fetching.value = false;
|
|
||||||
initializing.value = false;
|
|
||||||
|
|
||||||
updateCurrentAccountPartial({ hasUnreadChatMessages: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
let isActivated = true;
|
|
||||||
|
|
||||||
onActivated(() => {
|
|
||||||
isActivated = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
onDeactivated(() => {
|
|
||||||
isActivated = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
useInterval(() => {
|
|
||||||
// TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
|
|
||||||
if (!window.document.hidden && isActivated) {
|
|
||||||
fetchHistory();
|
|
||||||
}
|
|
||||||
}, 1000 * 10, {
|
|
||||||
immediate: false,
|
|
||||||
afterMounted: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
onActivated(() => {
|
|
||||||
fetchHistory();
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchHistory();
|
updateCurrentAccountPartial({ hasUnreadChatMessages: false });
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -207,77 +123,6 @@ onMounted(() => {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
padding: 16px 24px;
|
|
||||||
|
|
||||||
&.isRead,
|
|
||||||
&.isMe {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.isMe):not(.isRead) {
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 100%;
|
|
||||||
background-color: var(--MI_THEME-accent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageAvatar {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
margin: 0 16px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageBody {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: clip;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageHeaderName {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageHeaderUsername {
|
|
||||||
margin: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageHeaderTime {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageBodyText {
|
|
||||||
overflow: hidden;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.youSaid {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-right: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchResultItem {
|
.searchResultItem {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: solid 1px var(--MI_THEME-divider);
|
border: solid 1px var(--MI_THEME-divider);
|
||||||
|
|
|
@ -97,6 +97,7 @@ import XWidgetsColumn from '@/ui/deck/widgets-column.vue';
|
||||||
import XMentionsColumn from '@/ui/deck/mentions-column.vue';
|
import XMentionsColumn from '@/ui/deck/mentions-column.vue';
|
||||||
import XDirectColumn from '@/ui/deck/direct-column.vue';
|
import XDirectColumn from '@/ui/deck/direct-column.vue';
|
||||||
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
|
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
|
||||||
|
import XChatColumn from '@/ui/deck/chat-column.vue';
|
||||||
import { mainRouter } from '@/router.js';
|
import { mainRouter } from '@/router.js';
|
||||||
import { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js';
|
import { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js';
|
||||||
|
|
||||||
|
@ -114,6 +115,7 @@ const columnComponents = {
|
||||||
mentions: XMentionsColumn,
|
mentions: XMentionsColumn,
|
||||||
direct: XDirectColumn,
|
direct: XDirectColumn,
|
||||||
roleTimeline: XRoleTimelineColumn,
|
roleTimeline: XRoleTimelineColumn,
|
||||||
|
chat: XChatColumn,
|
||||||
};
|
};
|
||||||
|
|
||||||
mainRouter.navHook = (path, flag): boolean => {
|
mainRouter.navHook = (path, flag): boolean => {
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<XColumn :column="column" :isStacked="isStacked">
|
||||||
|
<template #header><i class="ti ti-messages" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.chat }}</template>
|
||||||
|
|
||||||
|
<div style="padding: 8px;">
|
||||||
|
<MkChatHistories/>
|
||||||
|
</div>
|
||||||
|
</XColumn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { i18n } from '../../i18n.js';
|
||||||
|
import XColumn from './column.vue';
|
||||||
|
import type { Column } from '@/deck.js';
|
||||||
|
import MkChatHistories from '@/components/MkChatHistories.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
column: Column;
|
||||||
|
isStacked: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
|
@ -0,0 +1,52 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MkContainer :showHeader="widgetProps.showHeader" class="mkw-chat">
|
||||||
|
<template #icon><i class="ti ti-users"></i></template>
|
||||||
|
<template #header>{{ i18n.ts._widgets.chat }}</template>
|
||||||
|
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configure()"><i class="ti ti-settings"></i></button></template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<MkChatHistories/>
|
||||||
|
</div>
|
||||||
|
</MkContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { } from 'vue';
|
||||||
|
import { useWidgetPropsManager } from './widget.js';
|
||||||
|
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
||||||
|
import type { GetFormResultType } from '@/utility/form.js';
|
||||||
|
import MkContainer from '@/components/MkContainer.vue';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import MkChatHistories from '@/components/MkChatHistories.vue';
|
||||||
|
|
||||||
|
const name = 'chat';
|
||||||
|
|
||||||
|
const widgetPropsDef = {
|
||||||
|
showHeader: {
|
||||||
|
type: 'boolean' as const,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
|
||||||
|
|
||||||
|
const props = defineProps<WidgetComponentProps<WidgetProps>>();
|
||||||
|
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
|
||||||
|
|
||||||
|
const { widgetProps, configure, save } = useWidgetPropsManager(name,
|
||||||
|
widgetPropsDef,
|
||||||
|
props,
|
||||||
|
emit,
|
||||||
|
);
|
||||||
|
|
||||||
|
defineExpose<WidgetComponentExpose>({
|
||||||
|
name,
|
||||||
|
configure,
|
||||||
|
id: props.widget ? props.widget.id : null,
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -35,6 +35,7 @@ export default function(app: App) {
|
||||||
app.component('WidgetUserList', defineAsyncComponent(() => import('./WidgetUserList.vue')));
|
app.component('WidgetUserList', defineAsyncComponent(() => import('./WidgetUserList.vue')));
|
||||||
app.component('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue')));
|
app.component('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue')));
|
||||||
app.component('WidgetBirthdayFollowings', defineAsyncComponent(() => import('./WidgetBirthdayFollowings.vue')));
|
app.component('WidgetBirthdayFollowings', defineAsyncComponent(() => import('./WidgetBirthdayFollowings.vue')));
|
||||||
|
app.component('WidgetChat', defineAsyncComponent(() => import('./WidgetChat.vue')));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 連合関連のウィジェット(連合無効時に隠す)
|
// 連合関連のウィジェット(連合無効時に隠す)
|
||||||
|
@ -70,6 +71,7 @@ export const widgets = [
|
||||||
'userList',
|
'userList',
|
||||||
'clicker',
|
'clicker',
|
||||||
'birthdayFollowings',
|
'birthdayFollowings',
|
||||||
|
'chat',
|
||||||
|
|
||||||
...federationWidgets,
|
...federationWidgets,
|
||||||
];
|
];
|
||||||
|
|
Loading…
Reference in New Issue