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
|
||||
- Feat: チャットウィジェットを追加
|
||||
- Feat: デッキにチャットカラムを追加
|
||||
- Enhance: Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように
|
||||
- Fix: ログアウトした際に処理が終了しない問題を修正
|
||||
- Fix: 自動バックアップが設定されている環境でログアウト直前に設定をバックアップするように
|
||||
- Fix: フォルダを開いた状態でメニューからアップロードしてもルートフォルダにアップロードされる問題を修正 #15836
|
||||
|
||||
### Server
|
||||
- Enhance: フォローしているユーザーならフォロワー限定投稿のノートでもアンテナで検知できるように
|
||||
(Cherry-picked from https://github.com/yojo-art/cherrypick/pull/568 and https://github.com/team-shahu/misskey/pull/38)
|
||||
- Fix: システムアカウントの名前がサーバー名と同期されない問題を修正
|
||||
|
||||
- Fix: 大文字を含むユーザの URL で紹介された場合に 404 エラーを返す問題 #15813
|
||||
- Fix: リードレプリカ設定時にレコードの追加・更新・削除を伴うクエリを発行した際はmasterノードで実行されるように調整( #10897 )
|
||||
|
||||
## 2025.4.0
|
||||
|
||||
|
@ -44,7 +50,7 @@
|
|||
- プラグイン、テーマ、クライアントに追加されたすべてのアカウント情報も含まれるようになりました
|
||||
- 自動で設定データをサーバーにバックアップできるように
|
||||
- 設定→設定のプロファイル→自動バックアップ で有効にできます
|
||||
- 新しいデバイスからログインしたり、ブラウザから設定データが消えてしまったときに自動で復元されます(復元をスキップすることも可能)
|
||||
- ログインしたとき、ブラウザから設定データが消えてしまったときに自動で復元されます(復元をスキップすることも可能)
|
||||
- 任意の設定項目をデバイス間で同期できるように
|
||||
- 設定項目の「...」メニュー→「デバイス間で同期」
|
||||
- 同期をオンにした際にサーバーに保存された値とローカルの値が競合する場合はどちらを優先するか選択できます
|
||||
|
@ -53,7 +59,7 @@
|
|||
- アカウントごとに設定値が分離される設定とそうでないクライアント設定が混在していた(かつ分離するかどうかを設定不可だった)のを、基本的に一律でクライアント全体に適用されるようにし、個別でアカウントごとに異なる設定を行えるように
|
||||
- 設定項目の「...」メニュー→「アカウントで上書き」をオンにすることで、設定値をそのアカウントでだけ適用するようにできます
|
||||
- ログアウトすると設定データもブラウザから消去されるようになりプライバシーが向上しました
|
||||
- 再度ログインすればサーバーのバックアップから設定データを復元可能です
|
||||
- バックアップを有効にしている場合、ログインした後にバックアップから設定データを復元可能です
|
||||
- エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました
|
||||
- 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です
|
||||
- 過去に作成されたバックアップデータとは現在互換性がありませんのでご注意ください
|
||||
|
|
|
@ -9207,6 +9207,10 @@ export interface Locale extends ILocale {
|
|||
* 今日誕生日のユーザー
|
||||
*/
|
||||
"birthdayFollowings": string;
|
||||
/**
|
||||
* チャット
|
||||
*/
|
||||
"chat": string;
|
||||
};
|
||||
"_cw": {
|
||||
/**
|
||||
|
@ -10230,6 +10234,10 @@ export interface Locale extends ILocale {
|
|||
* ロールタイムライン
|
||||
*/
|
||||
"roleTimeline": string;
|
||||
/**
|
||||
* チャット
|
||||
*/
|
||||
"chat": string;
|
||||
};
|
||||
};
|
||||
"_dialog": {
|
||||
|
|
|
@ -2421,6 +2421,7 @@ _widgets:
|
|||
chooseList: "リストを選択"
|
||||
clicker: "クリッカー"
|
||||
birthdayFollowings: "今日誕生日のユーザー"
|
||||
chat: "チャット"
|
||||
|
||||
_cw:
|
||||
hide: "隠す"
|
||||
|
@ -2705,6 +2706,7 @@ _deck:
|
|||
mentions: "あなた宛て"
|
||||
direct: "ダイレクト"
|
||||
roleTimeline: "ロールタイムライン"
|
||||
chat: "チャット"
|
||||
|
||||
_dialog:
|
||||
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
|
||||
|
|
|
@ -5,18 +5,19 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
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 { MiNote } from '@/models/Note.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.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 { CacheService } from './CacheService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
|
@ -37,6 +38,7 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
private cacheService: CacheService,
|
||||
private utilityService: UtilityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
|
@ -111,9 +113,6 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
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.excludeBots && noteUser.isBot) return false;
|
||||
|
@ -122,6 +121,18 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
|
||||
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') {
|
||||
// TODO
|
||||
} else if (antenna.src === 'list') {
|
||||
|
|
|
@ -54,7 +54,7 @@ export class FanoutTimelineEndpointService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
|
||||
async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
|
||||
// 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える
|
||||
if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]);
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
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 type { Config } from '@/config.js';
|
||||
import { intersperse } from '@/misc/prelude/array.js';
|
||||
|
@ -23,6 +23,8 @@ type ChildNode = DefaultTreeAdapterMap['childNode'];
|
|||
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
|
||||
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
|
||||
|
||||
export type Appender = (document: Document, body: HTMLParagraphElement) => void;
|
||||
|
||||
@Injectable()
|
||||
export class MfmService {
|
||||
constructor(
|
||||
|
@ -267,7 +269,7 @@ export class MfmService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
|
||||
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
|
||||
if (nodes == null) {
|
||||
return null;
|
||||
}
|
||||
|
@ -492,6 +494,10 @@ export class MfmService {
|
|||
|
||||
appendChildren(nodes, body);
|
||||
|
||||
for (const additionalAppender of additionalAppenders) {
|
||||
additionalAppender(doc, body);
|
||||
}
|
||||
|
||||
// Remove the unnecessary namespace
|
||||
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,
|
||||
username: user.username,
|
||||
host: user.host,
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatarBlurhash: user.avatarBlurhash,
|
||||
avatarUrl: user.avatarId == null ? null : user.avatarUrl,
|
||||
avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
|
||||
avatarDecorations: user.avatarDecorations.map(it => ({
|
||||
id: it.id,
|
||||
angle: it.angle,
|
||||
|
@ -441,8 +441,8 @@ export class WebhookTestService {
|
|||
createdAt: new Date().toISOString(),
|
||||
updatedAt: user.updatedAt?.toISOString() ?? null,
|
||||
lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
|
||||
bannerUrl: user.bannerUrl,
|
||||
bannerBlurhash: user.bannerBlurhash,
|
||||
bannerUrl: user.bannerId == null ? null : user.bannerUrl,
|
||||
bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
|
||||
isLocked: user.isLocked,
|
||||
isSilenced: false,
|
||||
isSuspended: user.isSuspended,
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { bindThis } from '@/decorators.js';
|
||||
import { extractApHashtagObjects } from './models/tag.js';
|
||||
|
@ -25,17 +25,17 @@ export class ApMfmService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, apAppend?: string) {
|
||||
public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, additionalAppender: Appender[] = []) {
|
||||
let noMisskeyContent = false;
|
||||
const srcMfm = (note.text ?? '') + (apAppend ?? '');
|
||||
const srcMfm = (note.text ?? '');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers));
|
||||
const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), additionalAppender);
|
||||
|
||||
return {
|
||||
content,
|
||||
|
|
|
@ -19,7 +19,7 @@ import type { MiEmoji } from '@/models/Emoji.js';
|
|||
import type { MiPoll } from '@/models/Poll.js';
|
||||
import type { MiPollVote } from '@/models/PollVote.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 { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import type { MiUserKeypair } from '@/models/UserKeypair.js';
|
||||
|
@ -430,10 +430,24 @@ export class ApRendererService {
|
|||
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||
}
|
||||
|
||||
let apAppend = '';
|
||||
const apAppend: Appender[] = [];
|
||||
|
||||
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;
|
||||
|
@ -509,7 +523,7 @@ export class ApRendererService {
|
|||
const urlPart = match[0];
|
||||
const urlPartParsed = new URL(urlPart);
|
||||
const restPart = maybeUrl.slice(match[0].length);
|
||||
|
||||
|
||||
return `<a href="${urlPartParsed.href}" rel="me nofollow noopener" target="_blank">${urlPart}</a>${restPart}`;
|
||||
} catch (e) {
|
||||
return maybeUrl;
|
||||
|
|
|
@ -486,8 +486,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
name: user.name,
|
||||
username: user.username,
|
||||
host: user.host,
|
||||
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
|
||||
avatarBlurhash: user.avatarBlurhash,
|
||||
avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? this.getIdenticonUrl(user),
|
||||
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 => ({
|
||||
id: ud.id,
|
||||
angle: ud.angle || undefined,
|
||||
|
@ -533,8 +533,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
createdAt: this.idService.parse(user.id).date.toISOString(),
|
||||
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
|
||||
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
|
||||
bannerUrl: user.bannerUrl,
|
||||
bannerBlurhash: user.bannerBlurhash,
|
||||
bannerUrl: user.bannerId == null ? null : user.bannerUrl,
|
||||
bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
|
||||
isLocked: user.isLocked,
|
||||
isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
|
||||
isSuspended: user.isSuspended,
|
||||
|
|
|
@ -118,21 +118,25 @@ export class MiUser {
|
|||
@JoinColumn()
|
||||
public banner: MiDriveFile | null;
|
||||
|
||||
// avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
})
|
||||
public avatarUrl: string | null;
|
||||
|
||||
// bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
})
|
||||
public bannerUrl: string | null;
|
||||
|
||||
// avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
})
|
||||
public avatarBlurhash: string | null;
|
||||
|
||||
// bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
})
|
||||
|
|
|
@ -3,29 +3,48 @@
|
|||
* 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 { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js';
|
||||
import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
|
||||
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
||||
import {
|
||||
RawSqlResultsToEntityTransformer,
|
||||
} from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
|
||||
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
|
||||
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
||||
import { MiAccessToken } from '@/models/AccessToken.js';
|
||||
import { MiAd } from '@/models/Ad.js';
|
||||
import { MiAnnouncement } from '@/models/Announcement.js';
|
||||
import { MiAnnouncementRead } from '@/models/AnnouncementRead.js';
|
||||
import { MiAntenna } from '@/models/Antenna.js';
|
||||
import { MiApp } from '@/models/App.js';
|
||||
import { MiAvatarDecoration } from '@/models/AvatarDecoration.js';
|
||||
import { MiAuthSession } from '@/models/AuthSession.js';
|
||||
import { MiAvatarDecoration } from '@/models/AvatarDecoration.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 { 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 { MiClipNote } from '@/models/ClipNote.js';
|
||||
import { MiClipFavorite } from '@/models/ClipFavorite.js';
|
||||
import { MiClipNote } from '@/models/ClipNote.js';
|
||||
import { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { MiDriveFolder } from '@/models/DriveFolder.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 { MiFollowRequest } from '@/models/FollowRequest.js';
|
||||
import { MiGalleryLike } from '@/models/GalleryLike.js';
|
||||
|
@ -35,7 +54,6 @@ import { MiInstance } from '@/models/Instance.js';
|
|||
import { MiMeta } from '@/models/Meta.js';
|
||||
import { MiModerationLog } from '@/models/ModerationLog.js';
|
||||
import { MiMuting } from '@/models/Muting.js';
|
||||
import { MiRenoteMuting } from '@/models/RenoteMuting.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
|
||||
import { MiNoteReaction } from '@/models/NoteReaction.js';
|
||||
|
@ -50,42 +68,38 @@ import { MiPromoRead } from '@/models/PromoRead.js';
|
|||
import { MiRegistrationTicket } from '@/models/RegistrationTicket.js';
|
||||
import { MiRegistryItem } from '@/models/RegistryItem.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 { MiSwSubscription } from '@/models/SwSubscription.js';
|
||||
import { MiSystemAccount } from '@/models/SystemAccount.js';
|
||||
import { MiSystemWebhook } from '@/models/SystemWebhook.js';
|
||||
import { MiUsedUsername } from '@/models/UsedUsername.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
import { MiUserIp } from '@/models/UserIp.js';
|
||||
import { MiUserKeypair } from '@/models/UserKeypair.js';
|
||||
import { MiUserList } from '@/models/UserList.js';
|
||||
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
|
||||
import { MiUserListMembership } from '@/models/UserListMembership.js';
|
||||
import { MiUserMemo } from '@/models/UserMemo.js';
|
||||
import { MiUserNotePining } from '@/models/UserNotePining.js';
|
||||
import { MiUserPending } from '@/models/UserPending.js';
|
||||
import { MiUserProfile } from '@/models/UserProfile.js';
|
||||
import { MiUserPublickey } from '@/models/UserPublickey.js';
|
||||
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
|
||||
import { MiUserMemo } from '@/models/UserMemo.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';
|
||||
|
||||
export interface MiRepository<T extends ObjectLiteral> {
|
||||
createTableColumnNames(this: Repository<T> & MiRepository<T>): string[];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -94,6 +108,21 @@ export const miRepository = {
|
|||
return this.metadata.columns.filter(column => column.isSelect && !column.isVirtual).map(column => column.databaseName);
|
||||
},
|
||||
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);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const mainAlias = queryBuilder.expressionMap.mainAlias!;
|
||||
|
@ -101,7 +130,9 @@ export const miRepository = {
|
|||
mainAlias.name = 't';
|
||||
const columnNames = this.createTableColumnNames();
|
||||
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
|
||||
builder.expressionMap.mainAlias!.tablePath = 'cte';
|
||||
this.selectAliasColumnNames(queryBuilder, builder);
|
||||
|
@ -204,7 +235,9 @@ export {
|
|||
};
|
||||
|
||||
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 AdsRepository = Repository<MiAd> & MiRepository<MiAd>;
|
||||
export type AnnouncementsRepository = Repository<MiAnnouncement> & MiRepository<MiAnnouncement>;
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
// https://github.com/typeorm/typeorm/issues/2400
|
||||
import pg from 'pg';
|
||||
import { DataSource, Logger } from 'typeorm';
|
||||
import { DataSource, Logger, type QueryRunner } from 'typeorm';
|
||||
import * as highlight from 'cli-highlight';
|
||||
import { entities as charts } from '@/core/chart/entities.js';
|
||||
import { Config } from '@/config.js';
|
||||
|
@ -96,6 +96,7 @@ const sqlLogger = dbLogger.createSubLogger('sql', 'gray');
|
|||
export type LoggerProps = {
|
||||
disableQueryTruncation?: boolean;
|
||||
enableQueryParamLogging?: boolean;
|
||||
printReplicationMode?: boolean,
|
||||
};
|
||||
|
||||
function highlightSql(sql: string) {
|
||||
|
@ -121,8 +122,10 @@ class MyCustomLogger implements Logger {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private transformQueryLog(sql: string) {
|
||||
let modded = sql;
|
||||
private transformQueryLog(sql: string, opts?: {
|
||||
prefix?: string;
|
||||
}) {
|
||||
let modded = opts?.prefix ? opts.prefix + sql : sql;
|
||||
if (!this.props.disableQueryTruncation) {
|
||||
modded = truncateSql(modded);
|
||||
}
|
||||
|
@ -140,18 +143,27 @@ class MyCustomLogger implements Logger {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public logQuery(query: string, parameters?: any[]) {
|
||||
sqlLogger.info(this.transformQueryLog(query), this.transformParameters(parameters));
|
||||
public logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) {
|
||||
const prefix = (this.props.printReplicationMode && queryRunner)
|
||||
? `[${queryRunner.getReplicationMode()}] `
|
||||
: undefined;
|
||||
sqlLogger.info(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public logQueryError(error: string, query: string, parameters?: any[]) {
|
||||
sqlLogger.error(this.transformQueryLog(query), this.transformParameters(parameters));
|
||||
public logQueryError(error: string, query: string, parameters?: any[], queryRunner?: QueryRunner) {
|
||||
const prefix = (this.props.printReplicationMode && queryRunner)
|
||||
? `[${queryRunner.getReplicationMode()}] `
|
||||
: undefined;
|
||||
sqlLogger.error(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public logQuerySlow(time: number, query: string, parameters?: any[]) {
|
||||
sqlLogger.warn(this.transformQueryLog(query), this.transformParameters(parameters));
|
||||
public logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) {
|
||||
const prefix = (this.props.printReplicationMode && queryRunner)
|
||||
? `[${queryRunner.getReplicationMode()}] `
|
||||
: undefined;
|
||||
sqlLogger.warn(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -298,6 +310,7 @@ export function createPostgresDataSource(config: Config) {
|
|||
? new MyCustomLogger({
|
||||
disableQueryTruncation: config.logging?.sql?.disableQueryTruncation,
|
||||
enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging,
|
||||
printReplicationMode: !!config.dbReplications,
|
||||
})
|
||||
: undefined,
|
||||
maxQueryExecutionTime: 300,
|
||||
|
|
|
@ -32,6 +32,7 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
|
|||
import * as Acct from '@/misc/acct.js';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
|
||||
import type { FindOptionsWhere } from 'typeorm';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
|
||||
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';
|
||||
|
@ -75,6 +76,7 @@ export class ActivityPubServerService {
|
|||
private queueService: QueueService,
|
||||
private userKeypairService: UserKeypairService,
|
||||
private queryService: QueryService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
) {
|
||||
//this.createServer = this.createServer.bind(this);
|
||||
}
|
||||
|
@ -461,16 +463,28 @@ export class ActivityPubServerService {
|
|||
const partOf = `${this.config.url}/users/${userId}/outbox`;
|
||||
|
||||
if (page) {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
|
||||
.andWhere('note.userId = :userId', { userId: user.id })
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
.andWhere('note.localOnly = FALSE');
|
||||
|
||||
const notes = await query.limit(limit).getMany();
|
||||
const notes = this.meta.enableFanoutTimeline ? await this.fanoutTimelineEndpointService.getMiNotes({
|
||||
sinceId: sinceId ?? null,
|
||||
untilId: untilId ?? null,
|
||||
limit: limit,
|
||||
allowPartial: false, // Possibly true? IDK it's OK for ordered collection.
|
||||
me: null,
|
||||
redisTimelines: [
|
||||
`userTimeline:${user.id}`,
|
||||
`userTimelineWithReplies:${user.id}`,
|
||||
],
|
||||
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();
|
||||
|
||||
|
@ -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
|
||||
private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) {
|
||||
if (this.meta.federation === 'none') {
|
||||
|
@ -735,7 +763,7 @@ export class ActivityPubServerService {
|
|||
const acct = Acct.parse(request.params.acct);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({
|
||||
usernameLower: acct.username,
|
||||
usernameLower: acct.username.toLowerCase(),
|
||||
host: acct.host ?? IsNull(),
|
||||
isSuspended: false,
|
||||
});
|
||||
|
|
|
@ -221,7 +221,7 @@ export class ServerService implements OnApplicationShutdown {
|
|||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
if (user) {
|
||||
reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user));
|
||||
reply.redirect((user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user));
|
||||
} else {
|
||||
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 =>
|
||||
!acct.host || acct.host === this.config.host.toLowerCase() ? {
|
||||
usernameLower: acct.username,
|
||||
usernameLower: acct.username.toLowerCase(),
|
||||
host: IsNull(),
|
||||
isSuspended: false,
|
||||
} : 422;
|
||||
|
|
|
@ -534,7 +534,7 @@ export class ClientServerService {
|
|||
|
||||
return await reply.view('user', {
|
||||
user, profile, me,
|
||||
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
||||
avatarUrl: _user.avatarUrl,
|
||||
sub: request.params.sub,
|
||||
...await this.generateCommonPugData(this.meta),
|
||||
clientCtx: htmlSafeJsonStringify({
|
||||
|
|
|
@ -65,7 +65,7 @@ export class FeedService {
|
|||
generator: 'Misskey',
|
||||
description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
|
||||
link: author.link,
|
||||
image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
||||
image: (user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user),
|
||||
feedLinks: {
|
||||
json: `${author.link}.json`,
|
||||
atom: `${author.link}.atom`,
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
import {
|
||||
api,
|
||||
failedApiCall,
|
||||
|
@ -19,6 +18,7 @@ import {
|
|||
userList,
|
||||
} from '../utils.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 => {
|
||||
return selector(a).localeCompare(selector(b));
|
||||
|
@ -235,12 +235,12 @@ describe('アンテナ', () => {
|
|||
await failedApiCall({
|
||||
endpoint: 'antennas/create',
|
||||
parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] },
|
||||
user: alice
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'EMPTY_KEYWORD',
|
||||
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a'
|
||||
})
|
||||
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a',
|
||||
});
|
||||
});
|
||||
//#endregion
|
||||
//#region 更新(antennas/update)
|
||||
|
@ -274,12 +274,12 @@ describe('アンテナ', () => {
|
|||
await failedApiCall({
|
||||
endpoint: 'antennas/update',
|
||||
parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] },
|
||||
user: alice
|
||||
user: alice,
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'EMPTY_KEYWORD',
|
||||
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4'
|
||||
})
|
||||
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4',
|
||||
});
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
@ -375,14 +375,23 @@ describe('アンテナ', () => {
|
|||
],
|
||||
},
|
||||
{
|
||||
// https://github.com/misskey-dev/misskey/issues/9025
|
||||
label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。',
|
||||
label: 'フォロワー限定投稿とDM投稿を含む',
|
||||
parameters: () => ({}),
|
||||
posts: [
|
||||
{ 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: 'followers' }) },
|
||||
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) },
|
||||
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }), included: true },
|
||||
{ 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({
|
||||
id: genAidx(Date.now()),
|
||||
username: un,
|
||||
usernameLower: un,
|
||||
usernameLower: un.toLowerCase(),
|
||||
...data,
|
||||
})
|
||||
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
|
|
@ -89,8 +89,8 @@ describe('SigninWithPasskeyApiService', () => {
|
|||
app = await Test.createTestingModule({
|
||||
imports: [GlobalModule, CoreModule],
|
||||
providers: [
|
||||
SigninWithPasskeyApiService,
|
||||
{ provide: RateLimiterService, useClass: FakeLimiter },
|
||||
SigninWithPasskeyApiService,
|
||||
{ provide: RateLimiterService, useClass: FakeLimiter },
|
||||
{ provide: SigninService, useClass: FakeSigninService },
|
||||
],
|
||||
}).useMocker((token) => {
|
||||
|
@ -115,7 +115,7 @@ describe('SigninWithPasskeyApiService', () => {
|
|||
jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify);
|
||||
|
||||
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 = {
|
||||
userId: uid,
|
||||
|
|
|
@ -74,7 +74,7 @@ describe('UserEntityService', () => {
|
|||
...userData,
|
||||
id: genAidx(Date.now()),
|
||||
username: un,
|
||||
usernameLower: un,
|
||||
usernameLower: un.toLowerCase(),
|
||||
})
|
||||
.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 + ')',
|
||||
icon: 'ti ti-upload',
|
||||
action: () => {
|
||||
chooseFileFromPc(true, { keepOriginal: false });
|
||||
chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: false });
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts.upload,
|
||||
icon: 'ti ti-upload',
|
||||
action: () => {
|
||||
chooseFileFromPc(true, { keepOriginal: true });
|
||||
chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: true });
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts.fromUrl,
|
||||
|
|
|
@ -38,6 +38,7 @@ export const columnTypes = [
|
|||
'mentions',
|
||||
'direct',
|
||||
'roleTimeline',
|
||||
'chat',
|
||||
] as const;
|
||||
|
||||
export type ColumnType = typeof columnTypes[number];
|
||||
|
|
|
@ -34,34 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkFoldableSection>
|
||||
<template #header>{{ i18n.ts._chat.history }}</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"/>
|
||||
<MkChatHistories/>
|
||||
</MkFoldableSection>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -81,20 +54,12 @@ import { updateCurrentAccountPartial } from '@/accounts.js';
|
|||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkChatHistories from '@/components/MkChatHistories.vue';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
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 searched = ref(false);
|
||||
const searchResults = ref<Misskey.entities.ChatMessage[]>([]);
|
||||
|
@ -148,57 +113,8 @@ async function search() {
|
|||
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(() => {
|
||||
fetchHistory();
|
||||
updateCurrentAccountPartial({ hasUnreadChatMessages: false });
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -207,77 +123,6 @@ onMounted(() => {
|
|||
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 {
|
||||
padding: 12px;
|
||||
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 XDirectColumn from '@/ui/deck/direct-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 { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js';
|
||||
|
||||
|
@ -114,6 +115,7 @@ const columnComponents = {
|
|||
mentions: XMentionsColumn,
|
||||
direct: XDirectColumn,
|
||||
roleTimeline: XRoleTimelineColumn,
|
||||
chat: XChatColumn,
|
||||
};
|
||||
|
||||
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('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue')));
|
||||
app.component('WidgetBirthdayFollowings', defineAsyncComponent(() => import('./WidgetBirthdayFollowings.vue')));
|
||||
app.component('WidgetChat', defineAsyncComponent(() => import('./WidgetChat.vue')));
|
||||
}
|
||||
|
||||
// 連合関連のウィジェット(連合無効時に隠す)
|
||||
|
@ -70,6 +71,7 @@ export const widgets = [
|
|||
'userList',
|
||||
'clicker',
|
||||
'birthdayFollowings',
|
||||
'chat',
|
||||
|
||||
...federationWidgets,
|
||||
];
|
||||
|
|
Loading…
Reference in New Issue