Merge branch 'develop' into fix-7311

This commit is contained in:
かっこかり 2024-04-20 22:37:09 +09:00 committed by GitHub
commit c6c18dbf07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 803 additions and 182 deletions

View File

@ -8,7 +8,14 @@
- Enhance: アンテナでBotによるートを除外できるように
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/545)
- Enhance: クリップのノート数を表示するように
- Enhance: コンディショナルロールの条件として以下を新たに追加 (#13667)
- 猫ユーザーか
- botユーザーか
- サスペンド済みユーザーか
- 鍵アカウントユーザーか
- 「アカウントを見つけやすくする」が有効なユーザーか
- Fix: Play作成時に設定した公開範囲が機能していない問題を修正
- Fix: 正規化されていない状態のhashtagが連合されてきたhtmlに含まれているとhashtagが正しくhashtagに復元されない問題を修正
- Fix: センシティブな画像をアイコン・バナーに指定できないように
### Client
@ -28,7 +35,8 @@
- Enhance: ノートについているリアクションの「もっと!」から、リアクションの一覧を表示できるように
- Enhance: リプライにて引用がある場合テキストが空でもノートできるように
- 引用したいートのURLをコピーしリプライ投稿画面にペーストして添付することで達成できます
- Enhance: アイコン画像・バナー画像を外せるように
- Enhance: フォローするかどうかの確認ダイアログを出せるように
- Enhance: アイコン画像・バナー画像を外せるように
- Fix: 一部のページ内リンクが正しく動作しない問題を修正
- Fix: 周年の実績が閏年を考慮しない問題を修正
- Fix: ローカルURLのプレビューポップアップが左上に表示される
@ -56,6 +64,9 @@
- Fix: 登録にメール認証が必須になっている場合、登録されているメールアドレスを削除できないように
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/606)
- Fix: nginx経由で/files/にRangeリクエストされた場合に正しく応答できないのを修正
- Fix: 一部のタイムラインのストリーミングでインスタンスミュートが効かない問題を修正
- Fix: グローバルタイムラインで返信が表示されないことがある問題を修正
- Fix: リノートをミュートしたユーザの投稿のリノートがミュートされる問題を修正
## 2024.3.1

24
locales/index.d.ts vendored
View File

@ -4948,6 +4948,10 @@ export interface Locale extends ILocale {
*
*/
"noDescription": string;
/**
*
*/
"alwaysConfirmFollow": string;
/**
*
*/
@ -6597,6 +6601,26 @@ export interface Locale extends ILocale {
*
*/
"isRemote": string;
/**
*
*/
"isCat": string;
/**
* botユーザー
*/
"isBot": string;
/**
*
*/
"isSuspended": string;
/**
*
*/
"isLocked": string;
/**
*
*/
"isExplorable": string;
/**
*
*/

View File

@ -1233,6 +1233,7 @@ useNativeUIForVideoAudioPlayer: "動画・音声の再生にブラウザのUIを
keepOriginalFilename: "オリジナルのファイル名を保持"
keepOriginalFilenameDescription: "この設定をオフにすると、アップロード時にファイル名が自動でランダム文字列に置き換えられます。"
noDescription: "説明文はありません"
alwaysConfirmFollow: "フォローの際常に確認する"
cannotSelectSensitiveMedia: "センシティブなメディアは選択できません"
cannotSelectSensitiveMediaDescription: "自分でセンシティブ設定を行っていないのにこのエラーが出ている場合、自動判定によりセンシティブなメディアとされている可能性があります。\nサーバーの規則に照らして不要な場合は、ファイルのセンシティブ設定を解除してもう一度お試しください。"
@ -1704,6 +1705,11 @@ _role:
roleAssignedTo: "マニュアルロールにアサイン済み"
isLocal: "ローカルユーザー"
isRemote: "リモートユーザー"
isCat: "猫ユーザー"
isBot: "botユーザー"
isSuspended: "サスペンド済みユーザー"
isLocked: "鍵アカウントユーザー"
isExplorable: "「アカウントを見つけやすくする」が有効なユーザー"
createdLessThan: "アカウント作成から~以内"
createdMoreThan: "アカウント作成から~経過"
followersLessThanOrEq: "フォロワー数が~以下"

View File

@ -116,7 +116,7 @@ export class FanoutTimelineEndpointService {
filter = (note) => {
if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;
if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
if (isRenote(note) && !isQuote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false;
if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false;
if (isInstanceMuted(note, userMutedInstances)) return false;
return parentFilter(note);

View File

@ -10,6 +10,7 @@ import { Window } from 'happy-dom';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { intersperse } from '@/misc/prelude/array.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js';
@ -33,6 +34,8 @@ export class MfmService {
// some AP servers like Pixelfed use br tags as well as newlines
html = html.replace(/<br\s?\/?>\r?\n/gi, '\n');
const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x)));
const dom = parse5.parseFragment(html);
let text = '';
@ -85,7 +88,7 @@ export class MfmService {
const href = node.attrs.find(x => x.name === 'href');
// ハッシュタグ
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) {
text += txt;
// メンション
} else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {

View File

@ -205,45 +205,79 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
try {
switch (value.type) {
// ~かつ~
case 'and': {
return value.values.every(v => this.evalCond(user, roles, v));
}
// ~または~
case 'or': {
return value.values.some(v => this.evalCond(user, roles, v));
}
// ~ではない
case 'not': {
return !this.evalCond(user, roles, value.value);
}
// マニュアルロールがアサインされている
case 'roleAssignedTo': {
return roles.some(r => r.id === value.roleId);
}
// ローカルユーザのみ
case 'isLocal': {
return this.userEntityService.isLocalUser(user);
}
// リモートユーザのみ
case 'isRemote': {
return this.userEntityService.isRemoteUser(user);
}
// サスペンド済みユーザである
case 'isSuspended': {
return user.isSuspended;
}
// 鍵アカウントユーザである
case 'isLocked': {
return user.isLocked;
}
// botユーザである
case 'isBot': {
return user.isBot;
}
// 猫である
case 'isCat': {
return user.isCat;
}
// 「ユーザを見つけやすくする」が有効なアカウント
case 'isExplorable': {
return user.isExplorable;
}
// ユーザが作成されてから指定期間経過した
case 'createdLessThan': {
return this.idService.parse(user.id).date.getTime() > (Date.now() - (value.sec * 1000));
}
// ユーザが作成されてから指定期間経っていない
case 'createdMoreThan': {
return this.idService.parse(user.id).date.getTime() < (Date.now() - (value.sec * 1000));
}
// フォロワー数が指定値以下
case 'followersLessThanOrEq': {
return user.followersCount <= value.value;
}
// フォロワー数が指定値以上
case 'followersMoreThanOrEq': {
return user.followersCount >= value.value;
}
// フォロー数が指定値以下
case 'followingLessThanOrEq': {
return user.followingCount <= value.value;
}
// フォロー数が指定値以上
case 'followingMoreThanOrEq': {
return user.followingCount >= value.value;
}
// ノート数が指定値以下
case 'notesLessThanOrEq': {
return user.notesCount <= value.value;
}
// ノート数が指定値以上
case 'notesMoreThanOrEq': {
return user.notesCount >= value.value;
}

View File

@ -4,6 +4,7 @@
*/
import type { MiNote } from '@/models/Note.js';
import type { Packed } from '@/misc/json-schema.js';
type Renote =
MiNote & {
@ -34,3 +35,33 @@ export function isQuote(note: Renote): note is Quote {
note.hasPoll ||
note.fileIds.length > 0;
}
type PackedRenote =
Packed<'Note'> & {
renoteId: NonNullable<Packed<'Note'>['renoteId']>
};
type PackedQuote =
PackedRenote & ({
text: NonNullable<Packed<'Note'>['text']>
} | {
cw: NonNullable<Packed<'Note'>['cw']>
} | {
replyId: NonNullable<Packed<'Note'>['replyId']>
} | {
poll: NonNullable<Packed<'Note'>['poll']>
} | {
fileIds: NonNullable<Packed<'Note'>['fileIds']>
});
export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote {
return note.renoteId != null;
}
export function isQuotePacked(note: PackedRenote): note is PackedQuote {
return note.text != null ||
note.cw != null ||
note.replyId != null ||
note.poll != null ||
(note.fileIds != null && note.fileIds.length > 0);
}

View File

@ -48,6 +48,7 @@ import {
packedRoleCondFormulaValueCreatedSchema,
packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
packedRoleCondFormulaValueSchema,
packedRoleCondFormulaValueUserSettingBooleanSchema,
} from '@/models/json-schema/role.js';
import { packedAdSchema } from '@/models/json-schema/ad.js';
import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js';
@ -97,6 +98,7 @@ export const refs = {
RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema,
RoleCondFormulaValueNot: packedRoleCondFormulaValueNot,
RoleCondFormulaValueIsLocalOrRemote: packedRoleCondFormulaValueIsLocalOrRemoteSchema,
RoleCondFormulaValueUserSettingBooleanSchema: packedRoleCondFormulaValueUserSettingBooleanSchema,
RoleCondFormulaValueAssignedRole: packedRoleCondFormulaValueAssignedRoleSchema,
RoleCondFormulaValueCreated: packedRoleCondFormulaValueCreatedSchema,
RoleCondFormulaFollowersOrFollowingOrNotes: packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,

View File

@ -6,69 +6,149 @@
import { Entity, Column, PrimaryColumn } from 'typeorm';
import { id } from './util/id.js';
/**
*
*
*/
type CondFormulaValueAnd = {
type: 'and';
values: RoleCondFormulaValue[];
};
/**
*
*
*/
type CondFormulaValueOr = {
type: 'or';
values: RoleCondFormulaValue[];
};
/**
*
*
*/
type CondFormulaValueNot = {
type: 'not';
value: RoleCondFormulaValue;
};
/**
*
*/
type CondFormulaValueIsLocal = {
type: 'isLocal';
};
/**
*
*/
type CondFormulaValueIsRemote = {
type: 'isRemote';
};
/**
*
*/
type CondFormulaValueRoleAssignedTo = {
type: 'roleAssignedTo';
roleId: string;
};
/**
*
*/
type CondFormulaValueIsSuspended = {
type: 'isSuspended';
};
/**
*
*/
type CondFormulaValueIsLocked = {
type: 'isLocked';
};
/**
* botアカウントの場合のみ成立とする
*/
type CondFormulaValueIsBot = {
type: 'isBot';
};
/**
*
*/
type CondFormulaValueIsCat = {
type: 'isCat';
};
/**
*
*/
type CondFormulaValueIsExplorable = {
type: 'isExplorable';
};
/**
*
*/
type CondFormulaValueCreatedLessThan = {
type: 'createdLessThan';
sec: number;
};
/**
*
*/
type CondFormulaValueCreatedMoreThan = {
type: 'createdMoreThan';
sec: number;
};
/**
*
*/
type CondFormulaValueFollowersLessThanOrEq = {
type: 'followersLessThanOrEq';
value: number;
};
/**
*
*/
type CondFormulaValueFollowersMoreThanOrEq = {
type: 'followersMoreThanOrEq';
value: number;
};
/**
*
*/
type CondFormulaValueFollowingLessThanOrEq = {
type: 'followingLessThanOrEq';
value: number;
};
/**
*
*/
type CondFormulaValueFollowingMoreThanOrEq = {
type: 'followingMoreThanOrEq';
value: number;
};
/**
* 稿
*/
type CondFormulaValueNotesLessThanOrEq = {
type: 'notesLessThanOrEq';
value: number;
};
/**
* 稿
*/
type CondFormulaValueNotesMoreThanOrEq = {
type: 'notesMoreThanOrEq';
value: number;
@ -80,6 +160,11 @@ export type RoleCondFormulaValue = { id: string } & (
CondFormulaValueNot |
CondFormulaValueIsLocal |
CondFormulaValueIsRemote |
CondFormulaValueIsSuspended |
CondFormulaValueIsLocked |
CondFormulaValueIsBot |
CondFormulaValueIsCat |
CondFormulaValueIsExplorable |
CondFormulaValueRoleAssignedTo |
CondFormulaValueCreatedLessThan |
CondFormulaValueCreatedMoreThan |

View File

@ -57,6 +57,20 @@ export const packedRoleCondFormulaValueIsLocalOrRemoteSchema = {
},
} as const;
export const packedRoleCondFormulaValueUserSettingBooleanSchema = {
type: 'object',
properties: {
id: {
type: 'string', optional: false,
},
type: {
type: 'string',
nullable: false, optional: false,
enum: ['isSuspended', 'isLocked', 'isBot', 'isCat', 'isExplorable'],
},
},
} as const;
export const packedRoleCondFormulaValueAssignedRoleSchema = {
type: 'object',
properties: {
@ -135,6 +149,9 @@ export const packedRoleCondFormulaValueSchema = {
{
ref: 'RoleCondFormulaValueIsLocalOrRemote',
},
{
ref: 'RoleCondFormulaValueUserSettingBooleanSchema',
},
{
ref: 'RoleCondFormulaValueAssignedRole',
},

View File

@ -4,6 +4,10 @@
*/
import { bindThis } from '@/decorators.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { Packed } from '@/misc/json-schema.js';
import type Connection from './Connection.js';
/**
@ -54,6 +58,24 @@ export default abstract class Channel {
return this.connection.subscriber;
}
/*
*
*/
protected isNoteMutedOrBlocked(note: Packed<'Note'>): boolean {
// 流れてきたNoteがインスタンスミュートしたインスタンスが関わる
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return true;
// 流れてきたNoteがミュートしているユーザーが関わる
if (isUserRelated(note, this.userIdsWhoMeMuting)) return true;
// 流れてきたNoteがブロックされているユーザーが関わる
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return true;
// 流れてきたNoteがリートをミュートしてるユーザが行ったもの
if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true;
return false;
}
constructor(id: string, connection: Connection) {
this.id = id;
this.connection = connection;

View File

@ -4,7 +4,6 @@
*/
import { Injectable } from '@nestjs/common';
import { isUserRelated } from '@/misc/is-user-related.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
@ -40,12 +39,7 @@ class AntennaChannel extends Channel {
if (data.type === 'note') {
const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true });
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
if (this.isNoteMutedOrBlocked(note)) return;
this.connection.cacheNote(note);

View File

@ -4,10 +4,10 @@
*/
import { Injectable } from '@nestjs/common';
import { isUserRelated } from '@/misc/is-user-related.js';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js';
class ChannelChannel extends Channel {
@ -38,14 +38,9 @@ class ChannelChannel extends Channel {
private async onNote(note: Packed<'Note'>) {
if (note.channelId !== this.channelId) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
if (this.user && note.renoteId && !note.text) {
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;

View File

@ -4,14 +4,12 @@
*/
import { Injectable } from '@nestjs/common';
import { checkWordMute } from '@/misc/check-word-mute.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import type { Packed } from '@/misc/json-schema.js';
import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js';
class GlobalTimelineChannel extends Channel {
@ -52,26 +50,11 @@ class GlobalTimelineChannel extends Channel {
if (note.visibility !== 'public') return;
if (note.channelId != null) return;
// 関係ない返信は除外
if (note.reply && !this.following[note.userId]?.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
}
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
if (this.isNoteMutedOrBlocked(note)) return;
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
if (this.user && note.renoteId && !note.text) {
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;

View File

@ -5,10 +5,10 @@
import { Injectable } from '@nestjs/common';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js';
class HashtagChannel extends Channel {
@ -43,14 +43,9 @@ class HashtagChannel extends Channel {
const matched = this.q.some(tags => tags.every(tag => noteTags.includes(normalizeForSearch(tag))));
if (!matched) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
if (this.user && note.renoteId && !note.text) {
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;

View File

@ -4,12 +4,10 @@
*/
import { Injectable } from '@nestjs/common';
import { checkWordMute } from '@/misc/check-word-mute.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js';
class HomeTimelineChannel extends Channel {
@ -51,9 +49,6 @@ class HomeTimelineChannel extends Channel {
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
}
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances))) return;
if (note.visibility === 'followers') {
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
} else if (note.visibility === 'specified') {
@ -72,7 +67,7 @@ class HomeTimelineChannel extends Channel {
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && note.poll == null) {
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
@ -81,14 +76,9 @@ class HomeTimelineChannel extends Channel {
}
}
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
if (this.user && note.renoteId && !note.text) {
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;

View File

@ -4,14 +4,12 @@
*/
import { Injectable } from '@nestjs/common';
import { checkWordMute } from '@/misc/check-word-mute.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import type { Packed } from '@/misc/json-schema.js';
import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js';
class HybridTimelineChannel extends Channel {
@ -71,8 +69,7 @@ class HybridTimelineChannel extends Channel {
if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return;
}
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances))) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (note.reply) {
const reply = note.reply;
@ -85,14 +82,7 @@ class HybridTimelineChannel extends Channel {
}
}
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
if (this.user && note.renoteId && !note.text) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {

View File

@ -4,13 +4,12 @@
*/
import { Injectable } from '@nestjs/common';
import { checkWordMute } from '@/misc/check-word-mute.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import type { Packed } from '@/misc/json-schema.js';
import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js';
class LocalTimelineChannel extends Channel {
@ -61,16 +60,11 @@ class LocalTimelineChannel extends Channel {
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
}
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
if (this.user && note.renoteId && !note.text) {
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;

View File

@ -4,8 +4,6 @@
*/
import { Injectable } from '@nestjs/common';
import { isUserRelated } from '@/misc/is-user-related.js';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
@ -46,12 +44,7 @@ class RoleTimelineChannel extends Channel {
}
if (note.visibility !== 'public') return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
if (this.isNoteMutedOrBlocked(note)) return;
this.send('note', note);
} else {

View File

@ -5,12 +5,11 @@
import { Inject, Injectable } from '@nestjs/common';
import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js';
class UserListChannel extends Channel {
@ -106,25 +105,17 @@ class UserListChannel extends Channel {
}
}
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return;
if (this.user && note.renoteId && !note.text) {
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
}
}
// 流れてきたNoteがミュートしているインスタンスに関わるものだったら無視する
if (isInstanceMuted(note, this.userMutedInstances)) return;
this.connection.cacheNote(note);
this.send('note', note);

View File

@ -63,6 +63,22 @@ describe('Renote Mute', () => {
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
});
// #12956
test('タイムラインにリノートミュートしているユーザーの通常ノートのリノートが含まれる', async () => {
const carolNote = await post(carol, { text: 'hi' });
const bobRenote = await post(bob, { renoteId: carolNote.id });
// redisに追加されるのを待つ
await sleep(100);
const res = await api('notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true);
});
test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => {
const bobNote = await post(bob, { text: 'hi' });
@ -86,4 +102,17 @@ describe('Renote Mute', () => {
assert.strictEqual(fired, true);
});
// #12956
test('ストリームにリノートミュートしているユーザーの通常ノートのリノートが流れてくる', async () => {
const carolbNote = await post(carol, { text: 'hi' });
const fired = await waitFire(
alice, 'localTimeline',
() => api('notes/create', { renoteId: carolbNote.id }, bob),
msg => msg.type === 'note' && msg.body.userId === bob.id,
);
assert.strictEqual(fired, true);
});
});

View File

@ -63,7 +63,7 @@ describe('Streaming', () => {
takumiNote = await post(takumi, { text: 'piyo' });
// Follow: ayano => kyoko
await api('following/create', { userId: kyoko.id }, ayano);
await api('following/create', { userId: kyoko.id, withReplies: false }, ayano);
// Follow: ayano => akari
await follow(ayano, akari);
@ -509,6 +509,16 @@ describe('Streaming', () => {
assert.strictEqual(fired, false);
});
test('withReplies = falseでフォローしてる人によるリプライが流れてくる', async () => {
const fired = await waitFire(
ayano, 'globalTimeline', // ayano:Global
() => api('notes/create', { text: 'foo', replyId: kanakoNote.id }, kyoko), // kyoko posts
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
);
assert.strictEqual(fired, true);
});
});
describe('UserList Timeline', () => {

View File

@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { UserEntityService } from '@/core/entities/UserEntityService.js';
process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
@ -20,6 +22,7 @@ import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { NotificationService } from '@/core/NotificationService.js';
import { RoleCondFormulaValue } from '@/models/Role.js';
import { sleep } from '../utils.js';
import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock';
@ -52,12 +55,26 @@ describe('RoleService', () => {
id: genAidx(Date.now()),
updatedAt: new Date(),
lastUsedAt: new Date(),
name: '',
description: '',
...data,
})
.then(x => rolesRepository.findOneByOrFail(x.identifiers[0]));
}
function createConditionalRole(condFormula: RoleCondFormulaValue, data: Partial<MiRole> = {}) {
return createRole({
name: `[conditional] ${condFormula.type}`,
target: 'conditional',
condFormula: condFormula,
...data,
});
}
function aidx() {
return genAidx(Date.now());
}
beforeEach(async () => {
clock = lolex.install({
now: new Date(),
@ -73,6 +90,7 @@ describe('RoleService', () => {
CacheService,
IdService,
GlobalEventService,
UserEntityService,
{
provide: NotificationService,
useFactory: () => ({
@ -209,79 +227,6 @@ describe('RoleService', () => {
expect(result.driveCapacityMb).toBe(100);
});
test('conditional role', async () => {
const user1 = await createUser({
id: genAidx(Date.now() - (1000 * 60 * 60 * 24 * 365)),
});
const user2 = await createUser({
id: genAidx(Date.now() - (1000 * 60 * 60 * 24 * 365)),
followersCount: 10,
});
await createRole({
name: 'a',
policies: {
canManageCustomEmojis: {
useDefault: false,
priority: 0,
value: true,
},
},
target: 'conditional',
condFormula: {
id: '232a4221-9816-49a6-a967-ae0fac52ec5e',
type: 'and',
values: [{
id: '2a37ef43-2d93-4c4d-87f6-f2fdb7d9b530',
type: 'followersMoreThanOrEq',
value: 10,
}, {
id: '1bd67839-b126-4f92-bad0-4e285dab453b',
type: 'createdMoreThan',
sec: 60 * 60 * 24 * 7,
}],
},
});
metaService.fetch.mockResolvedValue({
policies: {
canManageCustomEmojis: false,
},
} as any);
const user1Policies = await roleService.getUserPolicies(user1.id);
const user2Policies = await roleService.getUserPolicies(user2.id);
expect(user1Policies.canManageCustomEmojis).toBe(false);
expect(user2Policies.canManageCustomEmojis).toBe(true);
});
test('コンディショナルロール: マニュアルロールにアサイン済み', async () => {
const [user1, user2, role1] = await Promise.all([
createUser(),
createUser(),
createRole({
name: 'manual role',
}),
]);
const role2 = await createRole({
name: 'conditional role',
target: 'conditional',
condFormula: {
// idはバックエンドのロジックに必要ない
id: 'bdc612bd-9d54-4675-ae83-0499c82ea670',
type: 'roleAssignedTo',
roleId: role1.id,
},
});
await roleService.assign(user2.id, role1.id);
const [u1role, u2role] = await Promise.all([
roleService.getUserRoles(user1.id),
roleService.getUserRoles(user2.id),
]);
expect(u1role.some(r => r.id === role2.id)).toBe(false);
expect(u2role.some(r => r.id === role2.id)).toBe(true);
});
test('expired role', async () => {
const user = await createUser();
const role = await createRole({
@ -320,6 +265,427 @@ describe('RoleService', () => {
});
});
describe('conditional role', () => {
test('~かつ~', async () => {
const [user1, user2, user3, user4] = await Promise.all([
createUser({ isBot: true, isCat: false, isSuspended: false }),
createUser({ isBot: false, isCat: true, isSuspended: false }),
createUser({ isBot: true, isCat: true, isSuspended: false }),
createUser({ isBot: false, isCat: false, isSuspended: true }),
]);
const role1 = await createConditionalRole({
id: aidx(),
type: 'isBot',
});
const role2 = await createConditionalRole({
id: aidx(),
type: 'isCat',
});
const role3 = await createConditionalRole({
id: aidx(),
type: 'isSuspended',
});
const role4 = await createConditionalRole({
id: aidx(),
type: 'and',
values: [role1.condFormula, role2.condFormula],
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
const actual3 = await roleService.getUserRoles(user3.id);
const actual4 = await roleService.getUserRoles(user4.id);
expect(actual1.some(r => r.id === role4.id)).toBe(false);
expect(actual2.some(r => r.id === role4.id)).toBe(false);
expect(actual3.some(r => r.id === role4.id)).toBe(true);
expect(actual4.some(r => r.id === role4.id)).toBe(false);
});
test('~または~', async () => {
const [user1, user2, user3, user4] = await Promise.all([
createUser({ isBot: true, isCat: false, isSuspended: false }),
createUser({ isBot: false, isCat: true, isSuspended: false }),
createUser({ isBot: true, isCat: true, isSuspended: false }),
createUser({ isBot: false, isCat: false, isSuspended: true }),
]);
const role1 = await createConditionalRole({
id: aidx(),
type: 'isBot',
});
const role2 = await createConditionalRole({
id: aidx(),
type: 'isCat',
});
const role3 = await createConditionalRole({
id: aidx(),
type: 'isSuspended',
});
const role4 = await createConditionalRole({
id: aidx(),
type: 'or',
values: [role1.condFormula, role2.condFormula],
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
const actual3 = await roleService.getUserRoles(user3.id);
const actual4 = await roleService.getUserRoles(user4.id);
expect(actual1.some(r => r.id === role4.id)).toBe(true);
expect(actual2.some(r => r.id === role4.id)).toBe(true);
expect(actual3.some(r => r.id === role4.id)).toBe(true);
expect(actual4.some(r => r.id === role4.id)).toBe(false);
});
test('~ではない', async () => {
const [user1, user2, user3] = await Promise.all([
createUser({ isBot: true, isCat: false, isSuspended: false }),
createUser({ isBot: false, isCat: true, isSuspended: false }),
createUser({ isBot: true, isCat: true, isSuspended: false }),
]);
const role1 = await createConditionalRole({
id: aidx(),
type: 'isBot',
});
const role2 = await createConditionalRole({
id: aidx(),
type: 'isCat',
});
const role4 = await createConditionalRole({
id: aidx(),
type: 'not',
value: role1.condFormula,
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
const actual3 = await roleService.getUserRoles(user3.id);
expect(actual1.some(r => r.id === role4.id)).toBe(false);
expect(actual2.some(r => r.id === role4.id)).toBe(true);
expect(actual3.some(r => r.id === role4.id)).toBe(false);
});
test('マニュアルロールにアサイン済み', async () => {
const [user1, user2, role1] = await Promise.all([
createUser(),
createUser(),
createRole({
name: 'manual role',
}),
]);
const role2 = await createConditionalRole({
id: aidx(),
type: 'roleAssignedTo',
roleId: role1.id,
});
await roleService.assign(user2.id, role1.id);
const [u1role, u2role] = await Promise.all([
roleService.getUserRoles(user1.id),
roleService.getUserRoles(user2.id),
]);
expect(u1role.some(r => r.id === role2.id)).toBe(false);
expect(u2role.some(r => r.id === role2.id)).toBe(true);
});
test('ローカルユーザのみ', async () => {
const [user1, user2] = await Promise.all([
createUser({ host: null }),
createUser({ host: 'example.com' }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'isLocal',
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
expect(actual1.some(r => r.id === role.id)).toBe(true);
expect(actual2.some(r => r.id === role.id)).toBe(false);
});
test('リモートユーザのみ', async () => {
const [user1, user2] = await Promise.all([
createUser({ host: null }),
createUser({ host: 'example.com' }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'isRemote',
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
expect(actual1.some(r => r.id === role.id)).toBe(false);
expect(actual2.some(r => r.id === role.id)).toBe(true);
});
test('サスペンド済みユーザである', async () => {
const [user1, user2] = await Promise.all([
createUser({ isSuspended: false }),
createUser({ isSuspended: true }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'isSuspended',
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
expect(actual1.some(r => r.id === role.id)).toBe(false);
expect(actual2.some(r => r.id === role.id)).toBe(true);
});
test('鍵アカウントユーザである', async () => {
const [user1, user2] = await Promise.all([
createUser({ isLocked: false }),
createUser({ isLocked: true }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'isLocked',
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
expect(actual1.some(r => r.id === role.id)).toBe(false);
expect(actual2.some(r => r.id === role.id)).toBe(true);
});
test('botユーザである', async () => {
const [user1, user2] = await Promise.all([
createUser({ isBot: false }),
createUser({ isBot: true }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'isBot',
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
expect(actual1.some(r => r.id === role.id)).toBe(false);
expect(actual2.some(r => r.id === role.id)).toBe(true);
});
test('猫である', async () => {
const [user1, user2] = await Promise.all([
createUser({ isCat: false }),
createUser({ isCat: true }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'isCat',
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
expect(actual1.some(r => r.id === role.id)).toBe(false);
expect(actual2.some(r => r.id === role.id)).toBe(true);
});
test('「ユーザを見つけやすくする」が有効なアカウント', async () => {
const [user1, user2] = await Promise.all([
createUser({ isExplorable: false }),
createUser({ isExplorable: true }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'isExplorable',
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
expect(actual1.some(r => r.id === role.id)).toBe(false);
expect(actual2.some(r => r.id === role.id)).toBe(true);
});
test('ユーザが作成されてから指定期間経過した', async () => {
const base = new Date();
base.setMinutes(base.getMinutes() - 5);
const d1 = new Date(base);
const d2 = new Date(base);
const d3 = new Date(base);
d1.setSeconds(d1.getSeconds() - 1);
d3.setSeconds(d3.getSeconds() + 1);
const [user1, user2, user3] = await Promise.all([
// 4:59
createUser({ id: genAidx(d1.getTime()) }),
// 5:00
createUser({ id: genAidx(d2.getTime()) }),
// 5:01
createUser({ id: genAidx(d3.getTime()) }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'createdLessThan',
// 5 minutes
sec: 300,
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
const actual3 = await roleService.getUserRoles(user3.id);
expect(actual1.some(r => r.id === role.id)).toBe(false);
expect(actual2.some(r => r.id === role.id)).toBe(false);
expect(actual3.some(r => r.id === role.id)).toBe(true);
});
test('ユーザが作成されてから指定期間経っていない', async () => {
const base = new Date();
base.setMinutes(base.getMinutes() - 5);
const d1 = new Date(base);
const d2 = new Date(base);
const d3 = new Date(base);
d1.setSeconds(d1.getSeconds() - 1);
d3.setSeconds(d3.getSeconds() + 1);
const [user1, user2, user3] = await Promise.all([
// 4:59
createUser({ id: genAidx(d1.getTime()) }),
// 5:00
createUser({ id: genAidx(d2.getTime()) }),
// 5:01
createUser({ id: genAidx(d3.getTime()) }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'createdMoreThan',
// 5 minutes
sec: 300,
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
const actual3 = await roleService.getUserRoles(user3.id);
expect(actual1.some(r => r.id === role.id)).toBe(true);
expect(actual2.some(r => r.id === role.id)).toBe(false);
expect(actual3.some(r => r.id === role.id)).toBe(false);
});
test('フォロワー数が指定値以下', async () => {
const [user1, user2, user3] = await Promise.all([
createUser({ followersCount: 99 }),
createUser({ followersCount: 100 }),
createUser({ followersCount: 101 }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'followersLessThanOrEq',
value: 100,
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
const actual3 = await roleService.getUserRoles(user3.id);
expect(actual1.some(r => r.id === role.id)).toBe(true);
expect(actual2.some(r => r.id === role.id)).toBe(true);
expect(actual3.some(r => r.id === role.id)).toBe(false);
});
test('フォロワー数が指定値以下', async () => {
const [user1, user2, user3] = await Promise.all([
createUser({ followersCount: 99 }),
createUser({ followersCount: 100 }),
createUser({ followersCount: 101 }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'followersMoreThanOrEq',
value: 100,
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
const actual3 = await roleService.getUserRoles(user3.id);
expect(actual1.some(r => r.id === role.id)).toBe(false);
expect(actual2.some(r => r.id === role.id)).toBe(true);
expect(actual3.some(r => r.id === role.id)).toBe(true);
});
test('フォロー数が指定値以下', async () => {
const [user1, user2, user3] = await Promise.all([
createUser({ followingCount: 99 }),
createUser({ followingCount: 100 }),
createUser({ followingCount: 101 }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'followingLessThanOrEq',
value: 100,
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
const actual3 = await roleService.getUserRoles(user3.id);
expect(actual1.some(r => r.id === role.id)).toBe(true);
expect(actual2.some(r => r.id === role.id)).toBe(true);
expect(actual3.some(r => r.id === role.id)).toBe(false);
});
test('フォロー数が指定値以上', async () => {
const [user1, user2, user3] = await Promise.all([
createUser({ followingCount: 99 }),
createUser({ followingCount: 100 }),
createUser({ followingCount: 101 }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'followingMoreThanOrEq',
value: 100,
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
const actual3 = await roleService.getUserRoles(user3.id);
expect(actual1.some(r => r.id === role.id)).toBe(false);
expect(actual2.some(r => r.id === role.id)).toBe(true);
expect(actual3.some(r => r.id === role.id)).toBe(true);
});
test('ノート数が指定値以下', async () => {
const [user1, user2, user3] = await Promise.all([
createUser({ notesCount: 9 }),
createUser({ notesCount: 10 }),
createUser({ notesCount: 11 }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'notesLessThanOrEq',
value: 10,
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
const actual3 = await roleService.getUserRoles(user3.id);
expect(actual1.some(r => r.id === role.id)).toBe(true);
expect(actual2.some(r => r.id === role.id)).toBe(true);
expect(actual3.some(r => r.id === role.id)).toBe(false);
});
test('ノート数が指定値以上', async () => {
const [user1, user2, user3] = await Promise.all([
createUser({ notesCount: 9 }),
createUser({ notesCount: 10 }),
createUser({ notesCount: 11 }),
]);
const role = await createConditionalRole({
id: aidx(),
type: 'notesMoreThanOrEq',
value: 10,
});
const actual1 = await roleService.getUserRoles(user1.id);
const actual2 = await roleService.getUserRoles(user2.id);
const actual3 = await roleService.getUserRoles(user3.id);
expect(actual1.some(r => r.id === role.id)).toBe(false);
expect(actual2.some(r => r.id === role.id)).toBe(true);
expect(actual3.some(r => r.id === role.id)).toBe(true);
});
});
describe('assign', () => {
test('公開ロールの場合は通知される', async () => {
const user = await createUser();

View File

@ -93,6 +93,18 @@ async function onClick() {
userId: props.user.id,
});
} else {
if (defaultStore.state.alwaysConfirmFollow) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.tsx.followConfirm({ name: props.user.name || props.user.username }),
});
if (canceled) {
wait.value = false;
return;
}
}
if (hasPendingFollowRequestFromYou.value) {
await misskeyApi('following/requests/cancel', {
userId: props.user.id,

View File

@ -9,6 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSelect v-model="type" :class="$style.typeSelect">
<option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option>
<option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option>
<option value="isSuspended">{{ i18n.ts._role._condition.isSuspended }}</option>
<option value="isLocked">{{ i18n.ts._role._condition.isLocked }}</option>
<option value="isBot">{{ i18n.ts._role._condition.isBot }}</option>
<option value="isCat">{{ i18n.ts._role._condition.isCat }}</option>
<option value="isExplorable">{{ i18n.ts._role._condition.isExplorable }}</option>
<option value="roleAssignedTo">{{ i18n.ts._role._condition.roleAssignedTo }}</option>
<option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option>
<option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option>

View File

@ -12,6 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''"
:enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''"
:leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''"
mode="out-in"
>
<div v-if="page" :key="page.id" class="_gaps">
<div :class="$style.pageMain">
@ -41,8 +42,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.pageBannerTitle" class="_gaps_s">
<h1>{{ page.title || page.name }}</h1>
<div v-if="page.user" :class="$style.pageBannerTitleUser">
<MkAvatar :user="page.user" :class="$style.avatar" indicator link preview/> <MkA :to="`/@${username}`"><MkUserName :user="page.user" :nowrap="false"/></MkA>
<div :class="$style.pageBannerTitleSub">
<div v-if="page.user" :class="$style.pageBannerTitleUser">
<MkAvatar :user="page.user" :class="$style.avatar" indicator link preview/> <MkA :to="`/@${username}`"><MkUserName :user="page.user" :nowrap="false"/></MkA>
</div>
<div :class="$style.pageBannerTitleSubActions">
<MkA v-if="page.userId === $i?.id" v-tooltip="i18n.ts._pages.editThisPage" :to="`/pages/edit/${page.id}`" class="_button" :class="$style.generalActionButton"><i class="ti ti-pencil ti-fw"></i></MkA>
<button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button>
</div>
</div>
</div>
</div>
@ -355,8 +362,15 @@ definePageMetadata(() => ({
margin: 0;
}
.pageBannerTitleSub {
display: flex;
align-items: center;
width: 100%;
}
.pageBannerTitleUser {
--height: 32px;
flex-shrink: 0;
.avatar {
height: var(--height);
@ -365,6 +379,14 @@ definePageMetadata(() => ({
line-height: var(--height);
}
.pageBannerTitleSubActions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--marginHalf);
margin-left: auto;
}
}
}

View File

@ -165,6 +165,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="keepScreenOn">{{ i18n.ts.keepScreenOn }}</MkSwitch>
<MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch>
<MkSwitch v-model="enableHorizontalSwipe">{{ i18n.ts.enableHorizontalSwipe }}</MkSwitch>
<MkSwitch v-model="alwaysConfirmFollow">{{ i18n.ts.alwaysConfirmFollow }}</MkSwitch>
</div>
<MkSelect v-model="serverDisconnectedBehavior">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
@ -310,6 +311,7 @@ const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroup
const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect'));
const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe'));
const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer'));
const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
@ -351,6 +353,7 @@ watch([
keepScreenOn,
disableStreamingTimeline,
enableSeasonalScreenEffect,
alwaysConfirmFollow,
], async () => {
await reloadAsk();
});

View File

@ -450,6 +450,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: true,
},
alwaysConfirmFollow: {
where: 'device',
default: true,
},
sound_masterVolume: {
where: 'device',

View File

@ -1713,6 +1713,7 @@ declare namespace entities {
RoleCondFormulaLogics,
RoleCondFormulaValueNot,
RoleCondFormulaValueIsLocalOrRemote,
RoleCondFormulaValueUserSettingBooleanSchema,
RoleCondFormulaValueAssignedRole,
RoleCondFormulaValueCreated,
RoleCondFormulaFollowersOrFollowingOrNotes,
@ -2745,6 +2746,9 @@ type RoleCondFormulaValueIsLocalOrRemote = components['schemas']['RoleCondFormul
// @public (undocumented)
type RoleCondFormulaValueNot = components['schemas']['RoleCondFormulaValueNot'];
// @public (undocumented)
type RoleCondFormulaValueUserSettingBooleanSchema = components['schemas']['RoleCondFormulaValueUserSettingBooleanSchema'];
// @public (undocumented)
type RoleLite = components['schemas']['RoleLite'];

View File

@ -38,6 +38,7 @@ export type Signin = components['schemas']['Signin'];
export type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics'];
export type RoleCondFormulaValueNot = components['schemas']['RoleCondFormulaValueNot'];
export type RoleCondFormulaValueIsLocalOrRemote = components['schemas']['RoleCondFormulaValueIsLocalOrRemote'];
export type RoleCondFormulaValueUserSettingBooleanSchema = components['schemas']['RoleCondFormulaValueUserSettingBooleanSchema'];
export type RoleCondFormulaValueAssignedRole = components['schemas']['RoleCondFormulaValueAssignedRole'];
export type RoleCondFormulaValueCreated = components['schemas']['RoleCondFormulaValueCreated'];
export type RoleCondFormulaFollowersOrFollowingOrNotes = components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes'];

View File

@ -4586,6 +4586,11 @@ export type components = {
/** @enum {string} */
type: 'isLocal' | 'isRemote';
};
RoleCondFormulaValueUserSettingBooleanSchema: {
id: string;
/** @enum {string} */
type: 'isSuspended' | 'isLocked' | 'isBot' | 'isCat' | 'isExplorable';
};
RoleCondFormulaValueAssignedRole: {
id: string;
/** @enum {string} */
@ -4608,7 +4613,7 @@ export type components = {
type: 'followersLessThanOrEq' | 'followersMoreThanOrEq' | 'followingLessThanOrEq' | 'followingMoreThanOrEq' | 'notesLessThanOrEq' | 'notesMoreThanOrEq';
value: number;
};
RoleCondFormulaValue: components['schemas']['RoleCondFormulaLogics'] | components['schemas']['RoleCondFormulaValueNot'] | components['schemas']['RoleCondFormulaValueIsLocalOrRemote'] | components['schemas']['RoleCondFormulaValueAssignedRole'] | components['schemas']['RoleCondFormulaValueCreated'] | components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes'];
RoleCondFormulaValue: components['schemas']['RoleCondFormulaLogics'] | components['schemas']['RoleCondFormulaValueNot'] | components['schemas']['RoleCondFormulaValueIsLocalOrRemote'] | components['schemas']['RoleCondFormulaValueUserSettingBooleanSchema'] | components['schemas']['RoleCondFormulaValueAssignedRole'] | components['schemas']['RoleCondFormulaValueCreated'] | components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes'];
RoleLite: {
/**
* Format: id