Compare commits
25 commits
0da17148f9
...
afda33c0f4
Author | SHA1 | Date | |
---|---|---|---|
afda33c0f4 | |||
|
cace4153e4 | ||
|
f8b2e272f1 | ||
|
835e76152e | ||
|
c0c41af5f9 | ||
|
4430c12e0e | ||
|
1eb57201b4 | ||
|
0f68914610 | ||
|
d3a9995d0a | ||
|
c6ef944fc6 | ||
|
f6796a99ec | ||
|
32e2a07d66 | ||
|
b4bc58ae4c | ||
|
32b860c352 | ||
|
9dbdb97bb5 | ||
|
f402fd3313 | ||
|
0e4b7c91f1 | ||
|
0a0f3c3387 | ||
|
0cdb8e5b80 | ||
|
a46887d05f | ||
|
42d4fc9d97 | ||
|
da769846eb | ||
|
7f3dc6066d | ||
|
40a73bfcbe | ||
|
9f5c279478 |
28 changed files with 548 additions and 137 deletions
60
locales/index.d.ts
vendored
60
locales/index.d.ts
vendored
|
@ -7689,7 +7689,43 @@ export interface Locale extends ILocale {
|
||||||
* Match subdomains
|
* Match subdomains
|
||||||
*/
|
*/
|
||||||
"isFromInstanceSubdomains": string;
|
"isFromInstanceSubdomains": string;
|
||||||
|
/**
|
||||||
|
* Has X or fewer local followers
|
||||||
|
*/
|
||||||
|
"localFollowersLessThanOrEq": string;
|
||||||
|
/**
|
||||||
|
* Has X or more local followers
|
||||||
|
*/
|
||||||
|
"localFollowersMoreThanOrEq": string;
|
||||||
|
/**
|
||||||
|
* Follows X or fewer local accounts
|
||||||
|
*/
|
||||||
|
"localFollowingLessThanOrEq": string;
|
||||||
|
/**
|
||||||
|
* Follows X or more local accounts
|
||||||
|
*/
|
||||||
|
"localFollowingMoreThanOrEq": string;
|
||||||
|
/**
|
||||||
|
* Has X or fewer remote followers
|
||||||
|
*/
|
||||||
|
"remoteFollowersLessThanOrEq": string;
|
||||||
|
/**
|
||||||
|
* Has X or more remote followers
|
||||||
|
*/
|
||||||
|
"remoteFollowersMoreThanOrEq": string;
|
||||||
|
/**
|
||||||
|
* Follows X or fewer remote accounts
|
||||||
|
*/
|
||||||
|
"remoteFollowingLessThanOrEq": string;
|
||||||
|
/**
|
||||||
|
* Follows X or more remote accounts
|
||||||
|
*/
|
||||||
|
"remoteFollowingMoreThanOrEq": string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* This condition may be incorrect for remote users.
|
||||||
|
*/
|
||||||
|
"remoteDataWarning": string;
|
||||||
};
|
};
|
||||||
"_sensitiveMediaDetection": {
|
"_sensitiveMediaDetection": {
|
||||||
/**
|
/**
|
||||||
|
@ -12969,6 +13005,30 @@ export interface Locale extends ILocale {
|
||||||
*/
|
*/
|
||||||
"text": string;
|
"text": string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Test patterns
|
||||||
|
*/
|
||||||
|
"wordMuteTestLabel": string;
|
||||||
|
/**
|
||||||
|
* Enter some text here to test your word patterns. The matched words, if any, will be displayed below.
|
||||||
|
*/
|
||||||
|
"wordMuteTestDescription": string;
|
||||||
|
/**
|
||||||
|
* Test
|
||||||
|
*/
|
||||||
|
"wordMuteTestTest": string;
|
||||||
|
/**
|
||||||
|
* Matched words: {words}
|
||||||
|
*/
|
||||||
|
"wordMuteTestMatch": ParameterizedString<"words">;
|
||||||
|
/**
|
||||||
|
* No results yet, enter some text and click "Test" to check it.
|
||||||
|
*/
|
||||||
|
"wordMuteTestNoResults": string;
|
||||||
|
/**
|
||||||
|
* Text does not match any patterns.
|
||||||
|
*/
|
||||||
|
"wordMuteTestNoMatch": string;
|
||||||
}
|
}
|
||||||
declare const locales: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[lang: string]: Locale;
|
||||||
|
|
|
@ -15,6 +15,13 @@ import { bindThis } from '@/decorators.js';
|
||||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
|
||||||
|
export interface FollowStats {
|
||||||
|
localFollowing: number;
|
||||||
|
localFollowers: number;
|
||||||
|
remoteFollowing: number;
|
||||||
|
remoteFollowers: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CacheService implements OnApplicationShutdown {
|
export class CacheService implements OnApplicationShutdown {
|
||||||
public userByIdCache: MemoryKVCache<MiUser>;
|
public userByIdCache: MemoryKVCache<MiUser>;
|
||||||
|
@ -27,6 +34,7 @@ export class CacheService implements OnApplicationShutdown {
|
||||||
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
||||||
public renoteMutingsCache: RedisKVCache<Set<string>>;
|
public renoteMutingsCache: RedisKVCache<Set<string>>;
|
||||||
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
|
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
|
||||||
|
private readonly userFollowStatsCache = new MemoryKVCache<FollowStats>(1000 * 60 * 10); // 10 minutes
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.redis)
|
@Inject(DI.redis)
|
||||||
|
@ -167,6 +175,18 @@ export class CacheService implements OnApplicationShutdown {
|
||||||
const followee = this.userByIdCache.get(body.followeeId);
|
const followee = this.userByIdCache.get(body.followeeId);
|
||||||
if (followee) followee.followersCount++;
|
if (followee) followee.followersCount++;
|
||||||
this.userFollowingsCache.delete(body.followerId);
|
this.userFollowingsCache.delete(body.followerId);
|
||||||
|
this.userFollowStatsCache.delete(body.followerId);
|
||||||
|
this.userFollowStatsCache.delete(body.followeeId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'unfollow': {
|
||||||
|
const follower = this.userByIdCache.get(body.followerId);
|
||||||
|
if (follower) follower.followingCount--;
|
||||||
|
const followee = this.userByIdCache.get(body.followeeId);
|
||||||
|
if (followee) followee.followersCount--;
|
||||||
|
this.userFollowingsCache.delete(body.followerId);
|
||||||
|
this.userFollowStatsCache.delete(body.followerId);
|
||||||
|
this.userFollowStatsCache.delete(body.followeeId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
@ -187,6 +207,52 @@ export class CacheService implements OnApplicationShutdown {
|
||||||
}) ?? null;
|
}) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async getFollowStats(userId: MiUser['id']): Promise<FollowStats> {
|
||||||
|
return await this.userFollowStatsCache.fetch(userId, async () => {
|
||||||
|
const stats = {
|
||||||
|
localFollowing: 0,
|
||||||
|
localFollowers: 0,
|
||||||
|
remoteFollowing: 0,
|
||||||
|
remoteFollowers: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const followings = await this.followingsRepository.findBy([
|
||||||
|
{ followerId: userId },
|
||||||
|
{ followeeId: userId },
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const following of followings) {
|
||||||
|
if (following.followerId === userId) {
|
||||||
|
// increment following; user is a follower of someone else
|
||||||
|
if (following.followeeHost == null) {
|
||||||
|
stats.localFollowing++;
|
||||||
|
} else {
|
||||||
|
stats.remoteFollowing++;
|
||||||
|
}
|
||||||
|
} else if (following.followeeId === userId) {
|
||||||
|
// increment followers; user is followed by someone else
|
||||||
|
if (following.followerHost == null) {
|
||||||
|
stats.localFollowers++;
|
||||||
|
} else {
|
||||||
|
stats.remoteFollowers++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Should never happen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer remote-remote followers heuristically, since we don't track that info directly.
|
||||||
|
const user = await this.findUserById(userId);
|
||||||
|
if (user.host !== null) {
|
||||||
|
stats.remoteFollowing = Math.max(0, user.followingCount - stats.localFollowing);
|
||||||
|
stats.remoteFollowers = Math.max(0, user.followersCount - stats.localFollowers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.redisForSub.off('message', this.onMessage);
|
this.redisForSub.off('message', this.onMessage);
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import type { MiUserNotePining } from '@/models/UserNotePining.js';
|
import { MiUserNotePining } from '@/models/UserNotePining.js';
|
||||||
import { RelayService } from '@/core/RelayService.js';
|
import { RelayService } from '@/core/RelayService.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
|
@ -18,6 +18,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
|
||||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotePiningService {
|
export class NotePiningService {
|
||||||
|
@ -34,6 +35,9 @@ export class NotePiningService {
|
||||||
@Inject(DI.userNotePiningsRepository)
|
@Inject(DI.userNotePiningsRepository)
|
||||||
private userNotePiningsRepository: UserNotePiningsRepository,
|
private userNotePiningsRepository: UserNotePiningsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.db)
|
||||||
|
private readonly db: DataSource,
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private roleService: RoleService,
|
private roleService: RoleService,
|
||||||
|
@ -60,21 +64,23 @@ export class NotePiningService {
|
||||||
throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.');
|
throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id });
|
await this.db.transaction(async tem => {
|
||||||
|
const pinings = await tem.findBy(MiUserNotePining, { userId: user.id });
|
||||||
|
|
||||||
if (pinings.length >= (await this.roleService.getUserPolicies(user.id)).pinLimit) {
|
if (pinings.length >= (await this.roleService.getUserPolicies(user.id)).pinLimit) {
|
||||||
throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.');
|
throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pinings.some(pining => pining.noteId === note.id)) {
|
if (pinings.some(pining => pining.noteId === note.id)) {
|
||||||
throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.');
|
throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.userNotePiningsRepository.insert({
|
await tem.insert(MiUserNotePining, {
|
||||||
id: this.idService.gen(),
|
id: this.idService.gen(),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
noteId: note.id,
|
noteId: note.id,
|
||||||
} as MiUserNotePining);
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Deliver to remote followers
|
// Deliver to remote followers
|
||||||
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
|
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
|
||||||
|
|
|
@ -20,6 +20,7 @@ import type { MiUser } from '@/models/User.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import type { FollowStats } from '@/core/CacheService.js';
|
||||||
import type { RoleCondFormulaValue } from '@/models/Role.js';
|
import type { RoleCondFormulaValue } from '@/models/Role.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||||
|
@ -92,7 +93,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
||||||
canUpdateBioMedia: true,
|
canUpdateBioMedia: true,
|
||||||
pinLimit: 5,
|
pinLimit: 5,
|
||||||
antennaLimit: 5,
|
antennaLimit: 5,
|
||||||
wordMuteLimit: 200,
|
wordMuteLimit: 1000,
|
||||||
webhookLimit: 3,
|
webhookLimit: 3,
|
||||||
clipLimit: 10,
|
clipLimit: 10,
|
||||||
noteEachClipsLimit: 200,
|
noteEachClipsLimit: 200,
|
||||||
|
@ -221,20 +222,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
|
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue, followStats: FollowStats): boolean {
|
||||||
try {
|
try {
|
||||||
switch (value.type) {
|
switch (value.type) {
|
||||||
// ~かつ~
|
// ~かつ~
|
||||||
case 'and': {
|
case 'and': {
|
||||||
return value.values.every(v => this.evalCond(user, roles, v));
|
return value.values.every(v => this.evalCond(user, roles, v, followStats));
|
||||||
}
|
}
|
||||||
// ~または~
|
// ~または~
|
||||||
case 'or': {
|
case 'or': {
|
||||||
return value.values.some(v => this.evalCond(user, roles, v));
|
return value.values.some(v => this.evalCond(user, roles, v, followStats));
|
||||||
}
|
}
|
||||||
// ~ではない
|
// ~ではない
|
||||||
case 'not': {
|
case 'not': {
|
||||||
return !this.evalCond(user, roles, value.value);
|
return !this.evalCond(user, roles, value.value, followStats);
|
||||||
}
|
}
|
||||||
// マニュアルロールがアサインされている
|
// マニュアルロールがアサインされている
|
||||||
case 'roleAssignedTo': {
|
case 'roleAssignedTo': {
|
||||||
|
@ -305,6 +306,30 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
case 'followingMoreThanOrEq': {
|
case 'followingMoreThanOrEq': {
|
||||||
return user.followingCount >= value.value;
|
return user.followingCount >= value.value;
|
||||||
}
|
}
|
||||||
|
case 'localFollowersLessThanOrEq': {
|
||||||
|
return followStats.localFollowers <= value.value;
|
||||||
|
}
|
||||||
|
case 'localFollowersMoreThanOrEq': {
|
||||||
|
return followStats.localFollowers >= value.value;
|
||||||
|
}
|
||||||
|
case 'localFollowingLessThanOrEq': {
|
||||||
|
return followStats.localFollowing <= value.value;
|
||||||
|
}
|
||||||
|
case 'localFollowingMoreThanOrEq': {
|
||||||
|
return followStats.localFollowing >= value.value;
|
||||||
|
}
|
||||||
|
case 'remoteFollowersLessThanOrEq': {
|
||||||
|
return followStats.remoteFollowers <= value.value;
|
||||||
|
}
|
||||||
|
case 'remoteFollowersMoreThanOrEq': {
|
||||||
|
return followStats.remoteFollowers >= value.value;
|
||||||
|
}
|
||||||
|
case 'remoteFollowingLessThanOrEq': {
|
||||||
|
return followStats.remoteFollowing <= value.value;
|
||||||
|
}
|
||||||
|
case 'remoteFollowingMoreThanOrEq': {
|
||||||
|
return followStats.remoteFollowing >= value.value;
|
||||||
|
}
|
||||||
// ノート数が指定値以下
|
// ノート数が指定値以下
|
||||||
case 'notesLessThanOrEq': {
|
case 'notesLessThanOrEq': {
|
||||||
return user.notesCount <= value.value;
|
return user.notesCount <= value.value;
|
||||||
|
@ -340,10 +365,11 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getUserRoles(userId: MiUser['id']) {
|
public async getUserRoles(userId: MiUser['id']) {
|
||||||
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||||
|
const followStats = await this.cacheService.getFollowStats(userId);
|
||||||
const assigns = await this.getUserAssigns(userId);
|
const assigns = await this.getUserAssigns(userId);
|
||||||
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
||||||
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||||
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula));
|
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats));
|
||||||
return [...assignedRoles, ...matchedCondRoles];
|
return [...assignedRoles, ...matchedCondRoles];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -357,12 +383,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
// 期限切れのロールを除外
|
// 期限切れのロールを除外
|
||||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||||
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||||
|
const followStats = await this.cacheService.getFollowStats(userId);
|
||||||
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
||||||
const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
|
const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
|
||||||
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
|
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
|
||||||
if (badgeCondRoles.length > 0) {
|
if (badgeCondRoles.length > 0) {
|
||||||
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||||
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula));
|
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula, followStats));
|
||||||
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
|
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
|
||||||
} else {
|
} else {
|
||||||
return assignedBadgeRoles;
|
return assignedBadgeRoles;
|
||||||
|
|
|
@ -147,6 +147,70 @@ type CondFormulaValueFollowingMoreThanOrEq = {
|
||||||
value: number;
|
value: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is followed by at most N local users
|
||||||
|
*/
|
||||||
|
type CondFormulaValueLocalFollowersLessThanOrEq = {
|
||||||
|
type: 'localFollowersLessThanOrEq';
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is followed by at least N local users
|
||||||
|
*/
|
||||||
|
type CondFormulaValueLocalFollowersMoreThanOrEq = {
|
||||||
|
type: 'localFollowersMoreThanOrEq';
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is following at most N local users
|
||||||
|
*/
|
||||||
|
type CondFormulaValueLocalFollowingLessThanOrEq = {
|
||||||
|
type: 'localFollowingLessThanOrEq';
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is following at least N local users
|
||||||
|
*/
|
||||||
|
type CondFormulaValueLocalFollowingMoreThanOrEq = {
|
||||||
|
type: 'localFollowingMoreThanOrEq';
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is followed by at most N remote users
|
||||||
|
*/
|
||||||
|
type CondFormulaValueRemoteFollowersLessThanOrEq = {
|
||||||
|
type: 'remoteFollowersLessThanOrEq';
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is followed by at least N remote users
|
||||||
|
*/
|
||||||
|
type CondFormulaValueRemoteFollowersMoreThanOrEq = {
|
||||||
|
type: 'remoteFollowersMoreThanOrEq';
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is following at most N remote users
|
||||||
|
*/
|
||||||
|
type CondFormulaValueRemoteFollowingLessThanOrEq = {
|
||||||
|
type: 'remoteFollowingLessThanOrEq';
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is following at least N remote users
|
||||||
|
*/
|
||||||
|
type CondFormulaValueRemoteFollowingMoreThanOrEq = {
|
||||||
|
type: 'remoteFollowingMoreThanOrEq';
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 投稿数が指定値以下の場合のみ成立とする
|
* 投稿数が指定値以下の場合のみ成立とする
|
||||||
*/
|
*/
|
||||||
|
@ -182,6 +246,14 @@ export type RoleCondFormulaValue = { id: string } & (
|
||||||
CondFormulaValueFollowersMoreThanOrEq |
|
CondFormulaValueFollowersMoreThanOrEq |
|
||||||
CondFormulaValueFollowingLessThanOrEq |
|
CondFormulaValueFollowingLessThanOrEq |
|
||||||
CondFormulaValueFollowingMoreThanOrEq |
|
CondFormulaValueFollowingMoreThanOrEq |
|
||||||
|
CondFormulaValueLocalFollowersLessThanOrEq |
|
||||||
|
CondFormulaValueLocalFollowersMoreThanOrEq |
|
||||||
|
CondFormulaValueLocalFollowingLessThanOrEq |
|
||||||
|
CondFormulaValueLocalFollowingMoreThanOrEq |
|
||||||
|
CondFormulaValueRemoteFollowersLessThanOrEq |
|
||||||
|
CondFormulaValueRemoteFollowersMoreThanOrEq |
|
||||||
|
CondFormulaValueRemoteFollowingLessThanOrEq |
|
||||||
|
CondFormulaValueRemoteFollowingMoreThanOrEq |
|
||||||
CondFormulaValueNotesLessThanOrEq |
|
CondFormulaValueNotesLessThanOrEq |
|
||||||
CondFormulaValueNotesMoreThanOrEq
|
CondFormulaValueNotesMoreThanOrEq
|
||||||
);
|
);
|
||||||
|
|
|
@ -58,6 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
if (ps.attachedToLocalUserOnly) query.andWhere('tag.attachedLocalUsersCount != 0');
|
if (ps.attachedToLocalUserOnly) query.andWhere('tag.attachedLocalUsersCount != 0');
|
||||||
if (ps.attachedToRemoteUserOnly) query.andWhere('tag.attachedRemoteUsersCount != 0');
|
if (ps.attachedToRemoteUserOnly) query.andWhere('tag.attachedRemoteUsersCount != 0');
|
||||||
|
|
||||||
|
// Ignore hidden hashtags
|
||||||
|
query.andWhere(`
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM meta WHERE tag.name = ANY(meta."hiddenTags")
|
||||||
|
)`);
|
||||||
|
|
||||||
switch (ps.sort) {
|
switch (ps.sort) {
|
||||||
case '+mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'DESC'); break;
|
case '+mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'DESC'); break;
|
||||||
case '-mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'ASC'); break;
|
case '-mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'ASC'); break;
|
||||||
|
|
|
@ -330,8 +330,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
|
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
|
||||||
|
|
||||||
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
|
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
|
||||||
// TODO: ちゃんと数える
|
const length = mutedWords.reduce((sum, word) => {
|
||||||
const length = JSON.stringify(mutedWords).length;
|
const wordLength = Array.isArray(word)
|
||||||
|
? word.reduce((l, w) => l + w.length, 0)
|
||||||
|
: word.length;
|
||||||
|
return sum + wordLength;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
if (length > limit) {
|
if (length > limit) {
|
||||||
throw new ApiError(meta.errors.tooManyMutedWords);
|
throw new ApiError(meta.errors.tooManyMutedWords);
|
||||||
}
|
}
|
||||||
|
|
|
@ -173,23 +173,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
|
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
|
||||||
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
|
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
|
||||||
<template #name>
|
<template #name>
|
||||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
<MkUserName :user="appearNote.user"/>
|
||||||
<MkUserName :user="appearNote.user"/>
|
|
||||||
</MkA>
|
|
||||||
</template>
|
</template>
|
||||||
</I18n>
|
</I18n>
|
||||||
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
|
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
|
||||||
<template #name>
|
<template #name>
|
||||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
<MkUserName :user="appearNote.user"/>
|
||||||
<MkUserName :user="appearNote.user"/>
|
|
||||||
</MkA>
|
|
||||||
</template>
|
</template>
|
||||||
</I18n>
|
</I18n>
|
||||||
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
|
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
|
||||||
<template #name>
|
<template #name>
|
||||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
<MkUserName :user="appearNote.user"/>
|
||||||
<MkUserName :user="appearNote.user"/>
|
|
||||||
</MkA>
|
|
||||||
</template>
|
</template>
|
||||||
<template #word>
|
<template #word>
|
||||||
{{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
|
{{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
|
||||||
|
@ -1388,6 +1382,11 @@ function emitUpdReaction(emoji: string, delta: number) {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted:hover {
|
||||||
|
background: var(--MI_THEME-buttonBg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactionOmitted {
|
.reactionOmitted {
|
||||||
|
|
|
@ -232,9 +232,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
|
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
|
||||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||||
<template #name>
|
<template #name>
|
||||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
<MkUserName :user="appearNote.user"/>
|
||||||
<MkUserName :user="appearNote.user"/>
|
|
||||||
</MkA>
|
|
||||||
</template>
|
</template>
|
||||||
</I18n>
|
</I18n>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1199,5 +1197,10 @@ function animatedMFM() {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted:hover {
|
||||||
|
background: var(--MI_THEME-buttonBg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -75,9 +75,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div v-else :class="$style.muted" @click="muted = false">
|
<div v-else :class="$style.muted" @click="muted = false">
|
||||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||||
<template #name>
|
<template #name>
|
||||||
<MkA v-user-preview="note.userId" :to="userPage(note.user)">
|
<MkUserName :user="appearNote.user"/>
|
||||||
<MkUserName :user="note.user"/>
|
|
||||||
</MkA>
|
|
||||||
</template>
|
</template>
|
||||||
</I18n>
|
</I18n>
|
||||||
</div>
|
</div>
|
||||||
|
@ -518,5 +516,10 @@ if (props.detail) {
|
||||||
border: 1px solid var(--MI_THEME-divider);
|
border: 1px solid var(--MI_THEME-divider);
|
||||||
margin: 8px 8px 0 8px;
|
margin: 8px 8px 0 8px;
|
||||||
border-radius: var(--MI-radius-sm);
|
border-radius: var(--MI-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted:hover {
|
||||||
|
background: var(--MI_THEME-buttonBg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { computed, shallowRef } from 'vue';
|
import { computed, shallowRef } from 'vue';
|
||||||
import type { FollowingFeedTab } from '@/utility/following-feed-utils.js';
|
import type { FollowingFeedTab } from '@/types/following-feed.js';
|
||||||
import type { Paging } from '@/components/MkPagination.vue';
|
import type { Paging } from '@/components/MkPagination.vue';
|
||||||
import { infoImageUrl } from '@/instance.js';
|
import { infoImageUrl } from '@/instance.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
|
|
@ -174,23 +174,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
|
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
|
||||||
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
|
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
|
||||||
<template #name>
|
<template #name>
|
||||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
<MkUserName :user="appearNote.user"/>
|
||||||
<MkUserName :user="appearNote.user"/>
|
|
||||||
</MkA>
|
|
||||||
</template>
|
</template>
|
||||||
</I18n>
|
</I18n>
|
||||||
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
|
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
|
||||||
<template #name>
|
<template #name>
|
||||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
<MkUserName :user="appearNote.user"/>
|
||||||
<MkUserName :user="appearNote.user"/>
|
|
||||||
</MkA>
|
|
||||||
</template>
|
</template>
|
||||||
</I18n>
|
</I18n>
|
||||||
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
|
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
|
||||||
<template #name>
|
<template #name>
|
||||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
<MkUserName :user="appearNote.user"/>
|
||||||
<MkUserName :user="appearNote.user"/>
|
|
||||||
</MkA>
|
|
||||||
</template>
|
</template>
|
||||||
<template #word>
|
<template #word>
|
||||||
{{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
|
{{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
|
||||||
|
@ -1451,6 +1445,11 @@ function emitUpdReaction(emoji: string, delta: number) {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted:hover {
|
||||||
|
background: var(--MI_THEME-buttonBg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactionOmitted {
|
.reactionOmitted {
|
||||||
|
|
|
@ -237,9 +237,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
|
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
|
||||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||||
<template #name>
|
<template #name>
|
||||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
<MkUserName :user="appearNote.user"/>
|
||||||
<MkUserName :user="appearNote.user"/>
|
|
||||||
</MkA>
|
|
||||||
</template>
|
</template>
|
||||||
</I18n>
|
</I18n>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1273,6 +1271,11 @@ onUnmounted(() => {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted:hover {
|
||||||
|
background: var(--MI_THEME-buttonBg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badgeRoles {
|
.badgeRoles {
|
||||||
|
|
|
@ -83,9 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div v-else :class="$style.muted" @click="muted = false">
|
<div v-else :class="$style.muted" @click="muted = false">
|
||||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||||
<template #name>
|
<template #name>
|
||||||
<MkA v-user-preview="note.userId" :to="userPage(note.user)">
|
<MkUserName :user="appearNote.user"/>
|
||||||
<MkUserName :user="note.user"/>
|
|
||||||
</MkA>
|
|
||||||
</template>
|
</template>
|
||||||
</I18n>
|
</I18n>
|
||||||
</div>
|
</div>
|
||||||
|
@ -606,6 +604,11 @@ if (props.detail) {
|
||||||
border: 1px solid var(--MI_THEME-divider);
|
border: 1px solid var(--MI_THEME-divider);
|
||||||
margin: 8px 8px 0 8px;
|
margin: 8px 8px 0 8px;
|
||||||
border-radius: var(--MI-radius-sm);
|
border-radius: var(--MI-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted:hover {
|
||||||
|
background: var(--MI_THEME-buttonBg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// avatar container with line
|
// avatar container with line
|
||||||
|
|
57
packages/frontend/src/components/SkPatternTest.vue
Normal file
57
packages/frontend/src/components/SkPatternTest.vue
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MkFolder>
|
||||||
|
<template #label>{{ i18n.ts.wordMuteTestLabel }}</template>
|
||||||
|
|
||||||
|
<div class="_gaps">
|
||||||
|
<MkTextarea v-model="testWords">
|
||||||
|
<template #caption>{{ i18n.ts.wordMuteTestDescription }}</template>
|
||||||
|
</MkTextarea>
|
||||||
|
<div><MkButton :disabled="!testWords" @click="testWordMutes">{{ i18n.ts.wordMuteTestTest }}</MkButton></div>
|
||||||
|
<div v-if="testMatches == null">{{ i18n.ts.wordMuteTestNoResults}}</div>
|
||||||
|
<div v-else-if="testMatches === ''">{{ i18n.ts.wordMuteTestNoMatch }}</div>
|
||||||
|
<div v-else>{{ i18n.tsx.wordMuteTestMatch({ words: testMatches }) }}</div>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import MkTextarea from '@/components/MkTextarea.vue';
|
||||||
|
import { parseMutes } from '@/utility/parse-mutes';
|
||||||
|
import { checkWordMute } from '@/utility/check-word-mute';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
mutedWords?: string | null,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const testWords = ref<string | null>(null);
|
||||||
|
const testMatches = ref<string | null>(null);
|
||||||
|
|
||||||
|
function testWordMutes() {
|
||||||
|
if (!testWords.value || !props.mutedWords) {
|
||||||
|
testMatches.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mutes = parseMutes(props.mutedWords);
|
||||||
|
const matches = checkWordMute(testWords.value, null, mutes);
|
||||||
|
testMatches.value = matches ? matches.flat(2).join(', ') : '';
|
||||||
|
} catch {
|
||||||
|
// Error is displayed by above function
|
||||||
|
testMatches.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
|
@ -11,10 +11,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type { FollowingFeedModel } from '@/utility/following-feed-utils.js';
|
import type { FollowingFeedModel } from '@/types/following-feed.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import MkInfo from '@/components/MkInfo.vue';
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
import { followersTab } from '@/utility/following-feed-utils.js';
|
import { followersTab } from '@/types/following-feed.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
model: FollowingFeedModel,
|
model: FollowingFeedModel,
|
||||||
|
|
|
@ -22,6 +22,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<option value="followersMoreThanOrEq">{{ i18n.ts._role._condition.followersMoreThanOrEq }}</option>
|
<option value="followersMoreThanOrEq">{{ i18n.ts._role._condition.followersMoreThanOrEq }}</option>
|
||||||
<option value="followingLessThanOrEq">{{ i18n.ts._role._condition.followingLessThanOrEq }}</option>
|
<option value="followingLessThanOrEq">{{ i18n.ts._role._condition.followingLessThanOrEq }}</option>
|
||||||
<option value="followingMoreThanOrEq">{{ i18n.ts._role._condition.followingMoreThanOrEq }}</option>
|
<option value="followingMoreThanOrEq">{{ i18n.ts._role._condition.followingMoreThanOrEq }}</option>
|
||||||
|
<option value="localFollowersLessThanOrEq">{{ i18n.ts._role._condition.localFollowersLessThanOrEq }}</option>
|
||||||
|
<option value="localFollowersMoreThanOrEq">{{ i18n.ts._role._condition.localFollowersMoreThanOrEq }}</option>
|
||||||
|
<option value="localFollowingLessThanOrEq">{{ i18n.ts._role._condition.localFollowingLessThanOrEq }}</option>
|
||||||
|
<option value="localFollowingMoreThanOrEq">{{ i18n.ts._role._condition.localFollowingMoreThanOrEq }}</option>
|
||||||
|
<option value="remoteFollowersLessThanOrEq">{{ i18n.ts._role._condition.remoteFollowersLessThanOrEq }}</option>
|
||||||
|
<option value="remoteFollowersMoreThanOrEq">{{ i18n.ts._role._condition.remoteFollowersMoreThanOrEq }}</option>
|
||||||
|
<option value="remoteFollowingLessThanOrEq">{{ i18n.ts._role._condition.remoteFollowingLessThanOrEq }}</option>
|
||||||
|
<option value="remoteFollowingMoreThanOrEq">{{ i18n.ts._role._condition.remoteFollowingMoreThanOrEq }}</option>
|
||||||
<option value="notesLessThanOrEq">{{ i18n.ts._role._condition.notesLessThanOrEq }}</option>
|
<option value="notesLessThanOrEq">{{ i18n.ts._role._condition.notesLessThanOrEq }}</option>
|
||||||
<option value="notesMoreThanOrEq">{{ i18n.ts._role._condition.notesMoreThanOrEq }}</option>
|
<option value="notesMoreThanOrEq">{{ i18n.ts._role._condition.notesMoreThanOrEq }}</option>
|
||||||
<option value="and">{{ i18n.ts._role._condition.and }}</option>
|
<option value="and">{{ i18n.ts._role._condition.and }}</option>
|
||||||
|
@ -56,7 +64,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #suffix>sec</template>
|
<template #suffix>sec</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
|
|
||||||
<MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number">
|
<MkInput
|
||||||
|
v-else-if="[
|
||||||
|
'followersLessThanOrEq',
|
||||||
|
'followersMoreThanOrEq',
|
||||||
|
'followingLessThanOrEq',
|
||||||
|
'followingMoreThanOrEq',
|
||||||
|
'localFollowersLessThanOrEq',
|
||||||
|
'localFollowersMoreThanOrEq',
|
||||||
|
'localFollowingLessThanOrEq',
|
||||||
|
'localFollowingMoreThanOrEq',
|
||||||
|
'remoteFollowersLessThanOrEq',
|
||||||
|
'remoteFollowersMoreThanOrEq',
|
||||||
|
'remoteFollowingLessThanOrEq',
|
||||||
|
'remoteFollowingMoreThanOrEq',
|
||||||
|
'notesLessThanOrEq',
|
||||||
|
'notesMoreThanOrEq'
|
||||||
|
].includes(type)"
|
||||||
|
v-model="v.value"
|
||||||
|
type="number"
|
||||||
|
>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
|
|
||||||
<MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId">
|
<MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId">
|
||||||
|
@ -70,6 +97,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkSwitch v-if="type === 'isFromInstance'" v-model="v.subdomains">
|
<MkSwitch v-if="type === 'isFromInstance'" v-model="v.subdomains">
|
||||||
<template #label>{{ i18n.ts._role._condition.isFromInstanceSubdomains }}</template>
|
<template #label>{{ i18n.ts._role._condition.isFromInstanceSubdomains }}</template>
|
||||||
</MkSwitch>
|
</MkSwitch>
|
||||||
|
|
||||||
|
<div v-if="['remoteFollowersLessThanOrEq', 'remoteFollowersMoreThanOrEq', 'remoteFollowingLessThanOrEq', 'remoteFollowingMoreThanOrEq'].includes(type)" :class="$style.warningBanner">
|
||||||
|
<i class="ti ti-alert-triangle"></i>
|
||||||
|
{{ i18n.ts._role.remoteDataWarning }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -123,6 +155,14 @@ const type = computed({
|
||||||
if (t === 'followersMoreThanOrEq') v.value.value = 10;
|
if (t === 'followersMoreThanOrEq') v.value.value = 10;
|
||||||
if (t === 'followingLessThanOrEq') v.value.value = 10;
|
if (t === 'followingLessThanOrEq') v.value.value = 10;
|
||||||
if (t === 'followingMoreThanOrEq') v.value.value = 10;
|
if (t === 'followingMoreThanOrEq') v.value.value = 10;
|
||||||
|
if (t === 'localFollowersLessThanOrEq') v.value.value = 10;
|
||||||
|
if (t === 'localFollowersMoreThanOrEq') v.value.value = 10;
|
||||||
|
if (t === 'localFollowingLessThanOrEq') v.value.value = 10;
|
||||||
|
if (t === 'localFollowingMoreThanOrEq') v.value.value = 10;
|
||||||
|
if (t === 'remoteFollowersLessThanOrEq') v.value.value = 10;
|
||||||
|
if (t === 'remoteFollowersMoreThanOrEq') v.value.value = 10;
|
||||||
|
if (t === 'remoteFollowingLessThanOrEq') v.value.value = 10;
|
||||||
|
if (t === 'remoteFollowingMoreThanOrEq') v.value.value = 10;
|
||||||
if (t === 'notesLessThanOrEq') v.value.value = 10;
|
if (t === 'notesLessThanOrEq') v.value.value = 10;
|
||||||
if (t === 'notesMoreThanOrEq') v.value.value = 10;
|
if (t === 'notesMoreThanOrEq') v.value.value = 10;
|
||||||
if (t === 'isFromInstance') {
|
if (t === 'isFromInstance') {
|
||||||
|
@ -178,4 +218,14 @@ function removeSelf() {
|
||||||
border-color: var(--MI_THEME-accent);
|
border-color: var(--MI_THEME-accent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.warningBanner {
|
||||||
|
color: var(--MI_THEME-warn);
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 6px;
|
||||||
|
|
||||||
|
> i {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -47,6 +47,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkTextarea v-model="trustedLinkUrlPatterns">
|
<MkTextarea v-model="trustedLinkUrlPatterns">
|
||||||
<template #caption>{{ i18n.ts.trustedLinkUrlPatternsDescription }}</template>
|
<template #caption>{{ i18n.ts.trustedLinkUrlPatternsDescription }}</template>
|
||||||
</MkTextarea>
|
</MkTextarea>
|
||||||
|
|
||||||
|
<SkPatternTest :mutedWords="trustedLinkUrlPatterns"></SkPatternTest>
|
||||||
|
|
||||||
<MkButton primary @click="save_trustedLinkUrlPatterns">{{ i18n.ts.save }}</MkButton>
|
<MkButton primary @click="save_trustedLinkUrlPatterns">{{ i18n.ts.save }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
@ -71,6 +74,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkTextarea v-model="sensitiveWords">
|
<MkTextarea v-model="sensitiveWords">
|
||||||
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
|
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
|
||||||
</MkTextarea>
|
</MkTextarea>
|
||||||
|
|
||||||
|
<SkPatternTest :mutedWords="sensitiveWords"></SkPatternTest>
|
||||||
|
|
||||||
<MkButton primary @click="save_sensitiveWords">{{ i18n.ts.save }}</MkButton>
|
<MkButton primary @click="save_sensitiveWords">{{ i18n.ts.save }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
@ -83,6 +89,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkTextarea v-model="prohibitedWords">
|
<MkTextarea v-model="prohibitedWords">
|
||||||
<template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
|
<template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
|
||||||
</MkTextarea>
|
</MkTextarea>
|
||||||
|
|
||||||
|
<SkPatternTest :mutedWords="prohibitedWords"></SkPatternTest>
|
||||||
|
|
||||||
<MkButton primary @click="save_prohibitedWords">{{ i18n.ts.save }}</MkButton>
|
<MkButton primary @click="save_prohibitedWords">{{ i18n.ts.save }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
@ -95,6 +104,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkTextarea v-model="prohibitedWordsForNameOfUser">
|
<MkTextarea v-model="prohibitedWordsForNameOfUser">
|
||||||
<template #caption>{{ i18n.ts.prohibitedWordsForNameOfUserDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
|
<template #caption>{{ i18n.ts.prohibitedWordsForNameOfUserDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
|
||||||
</MkTextarea>
|
</MkTextarea>
|
||||||
|
|
||||||
|
<SkPatternTest :mutedWords="prohibitedWordsForNameOfUser"></SkPatternTest>
|
||||||
|
|
||||||
<MkButton primary @click="save_prohibitedWordsForNameOfUser">{{ i18n.ts.save }}</MkButton>
|
<MkButton primary @click="save_prohibitedWordsForNameOfUser">{{ i18n.ts.save }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
@ -166,6 +178,7 @@ import { definePage } from '@/page.js';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import FormLink from '@/components/form/link.vue';
|
import FormLink from '@/components/form/link.vue';
|
||||||
import MkFolder from '@/components/MkFolder.vue';
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
import SkPatternTest from '@/components/SkPatternTest.vue';
|
||||||
|
|
||||||
const enableRegistration = ref<boolean>(false);
|
const enableRegistration = ref<boolean>(false);
|
||||||
const emailRequiredForSignup = ref<boolean>(false);
|
const emailRequiredForSignup = ref<boolean>(false);
|
||||||
|
|
|
@ -33,7 +33,8 @@ import { i18n } from '@/i18n.js';
|
||||||
import MkSwiper from '@/components/MkSwiper.vue';
|
import MkSwiper from '@/components/MkSwiper.vue';
|
||||||
import MkPageHeader from '@/components/global/MkPageHeader.vue';
|
import MkPageHeader from '@/components/global/MkPageHeader.vue';
|
||||||
import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue';
|
import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue';
|
||||||
import { createModel, createHeaderItem, followingFeedTabs, followingTabIcon, followingTabName, followingTab } from '@/utility/following-feed-utils.js';
|
import { createModel, createHeaderItem, followingTabIcon, followingTabName } from '@/utility/following-feed-utils.js';
|
||||||
|
import { followingTab, followingFeedTabs } from '@/types/following-feed.js';
|
||||||
import SkFollowingRecentNotes from '@/components/SkFollowingRecentNotes.vue';
|
import SkFollowingRecentNotes from '@/components/SkFollowingRecentNotes.vue';
|
||||||
import SkRemoteFollowersWarning from '@/components/SkRemoteFollowersWarning.vue';
|
import SkRemoteFollowersWarning from '@/components/SkRemoteFollowersWarning.vue';
|
||||||
import { useRouter } from '@/router.js';
|
import { useRouter } from '@/router.js';
|
||||||
|
|
|
@ -11,6 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
|
<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
|
||||||
</MkTextarea>
|
</MkTextarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SkPatternTest :mutedWords="mutedWords"></SkPatternTest>
|
||||||
|
|
||||||
<MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
|
<MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -19,8 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import MkTextarea from '@/components/MkTextarea.vue';
|
import MkTextarea from '@/components/MkTextarea.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import * as os from '@/os.js';
|
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { parseMutes } from '@/utility/parse-mutes';
|
||||||
|
import SkPatternTest from '@/components/SkPatternTest.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
muted: (string[] | string)[];
|
muted: (string[] | string)[];
|
||||||
|
@ -30,7 +34,7 @@ const emit = defineEmits<{
|
||||||
(ev: 'save', value: (string[] | string)[]): void;
|
(ev: 'save', value: (string[] | string)[]): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const render = (mutedWords) => mutedWords.map(x => {
|
const render = (mutedWords: (string | string[])[]) => mutedWords.map(x => {
|
||||||
if (Array.isArray(x)) {
|
if (Array.isArray(x)) {
|
||||||
return x.join(' ');
|
return x.join(' ');
|
||||||
} else {
|
} else {
|
||||||
|
@ -46,47 +50,15 @@ watch(mutedWords, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
const parseMutes = (mutes) => {
|
|
||||||
// split into lines, remove empty lines and unnecessary whitespace
|
|
||||||
let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== '');
|
|
||||||
|
|
||||||
// check each line if it is a RegExp or not
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
const regexp = line.match(/^\/(.+)\/(.*)$/);
|
|
||||||
if (regexp) {
|
|
||||||
// check that the RegExp is valid
|
|
||||||
try {
|
|
||||||
new RegExp(regexp[1], regexp[2]);
|
|
||||||
// note that regex lines will not be split by spaces!
|
|
||||||
} catch (err: any) {
|
|
||||||
// invalid syntax: do not save, do not reset changed flag
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
title: i18n.ts.regexpError,
|
|
||||||
text: i18n.tsx.regexpErrorDescription({ tab: 'word mute', line: i + 1 }) + '\n' + err.toString(),
|
|
||||||
});
|
|
||||||
// re-throw error so these invalid settings are not saved
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lines[i] = line.split(' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
};
|
|
||||||
|
|
||||||
let parsed;
|
|
||||||
try {
|
try {
|
||||||
parsed = parseMutes(mutedWords.value);
|
const parsed = parseMutes(mutedWords.value);
|
||||||
} catch (err) {
|
|
||||||
|
emit('save', parsed);
|
||||||
|
|
||||||
|
changed.value = false;
|
||||||
|
} catch {
|
||||||
// already displayed error message in parseMutes
|
// already displayed error message in parseMutes
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('save', parsed);
|
|
||||||
|
|
||||||
changed.value = false;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -11,10 +11,10 @@ import type { Plugin } from '@/plugin.js';
|
||||||
import type { DeviceKind } from '@/utility/device-kind.js';
|
import type { DeviceKind } from '@/utility/device-kind.js';
|
||||||
import type { DeckProfile } from '@/deck.js';
|
import type { DeckProfile } from '@/deck.js';
|
||||||
import type { PreferencesDefinition } from './manager.js';
|
import type { PreferencesDefinition } from './manager.js';
|
||||||
import type { FollowingFeedState } from '@/utility/following-feed-utils.js';
|
import type { FollowingFeedState } from '@/types/following-feed.js';
|
||||||
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
||||||
import { searchEngineMap } from '@/utility/search-engine-map.js';
|
import { searchEngineMap } from '@/utility/search-engine-map.js';
|
||||||
import { defaultFollowingFeedState } from '@/utility/following-feed-utils.js';
|
import { defaultFollowingFeedState } from '@/types/following-feed.js';
|
||||||
|
|
||||||
/** サウンド設定 */
|
/** サウンド設定 */
|
||||||
export type SoundStore = {
|
export type SoundStore = {
|
||||||
|
|
|
@ -10,11 +10,11 @@ import darkTheme from '@@/themes/d-green-lime.json5';
|
||||||
import { hemisphere } from '@@/js/intl-const.js';
|
import { hemisphere } from '@@/js/intl-const.js';
|
||||||
import type { DeviceKind } from '@/utility/device-kind.js';
|
import type { DeviceKind } from '@/utility/device-kind.js';
|
||||||
import type { Plugin } from '@/plugin.js';
|
import type { Plugin } from '@/plugin.js';
|
||||||
|
import type { FollowingFeedState } from '@/types/following-feed.js';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
import { miLocalStorage } from '@/local-storage.js';
|
||||||
import { Pizzax } from '@/lib/pizzax.js';
|
import { Pizzax } from '@/lib/pizzax.js';
|
||||||
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
||||||
import { defaultFollowingFeedState } from '@/utility/following-feed-utils.js';
|
import { defaultFollowingFeedState } from '@/types/following-feed.js';
|
||||||
import type { FollowingFeedState } from '@/utility/following-feed-utils.js';
|
|
||||||
import { searchEngineMap } from '@/utility/search-engine-map.js';
|
import { searchEngineMap } from '@/utility/search-engine-map.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
36
packages/frontend/src/types/following-feed.ts
Normal file
36
packages/frontend/src/types/following-feed.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WritableComputedRef } from 'vue';
|
||||||
|
|
||||||
|
export const followingTab = 'following' as const;
|
||||||
|
export const mutualsTab = 'mutuals' as const;
|
||||||
|
export const followersTab = 'followers' as const;
|
||||||
|
export const followingFeedTabs = [followingTab, mutualsTab, followersTab] as const;
|
||||||
|
export type FollowingFeedTab = typeof followingFeedTabs[number];
|
||||||
|
|
||||||
|
export type FollowingFeedState = {
|
||||||
|
withNonPublic: boolean,
|
||||||
|
withQuotes: boolean,
|
||||||
|
withBots: boolean,
|
||||||
|
withReplies: boolean,
|
||||||
|
onlyFiles: boolean,
|
||||||
|
userList: FollowingFeedTab,
|
||||||
|
remoteWarningDismissed: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FollowingFeedModel = {
|
||||||
|
[Key in keyof FollowingFeedState]: WritableComputedRef<FollowingFeedState[Key]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultFollowingFeedState: FollowingFeedState = {
|
||||||
|
withNonPublic: false,
|
||||||
|
withQuotes: false,
|
||||||
|
withBots: true,
|
||||||
|
withReplies: false,
|
||||||
|
onlyFiles: false,
|
||||||
|
userList: followingTab,
|
||||||
|
remoteWarningDismissed: false,
|
||||||
|
};
|
|
@ -19,18 +19,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, shallowRef } from 'vue';
|
import { computed, shallowRef } from 'vue';
|
||||||
import type { Column } from '@/deck.js';
|
import type { Column } from '@/deck.js';
|
||||||
import type { FollowingFeedState } from '@/utility/following-feed-utils.js';
|
import type { FollowingFeedState } from '@/types/following-feed.js';
|
||||||
export type FollowingColumn = Column & Partial<FollowingFeedState>;
|
export type FollowingColumn = Column & Partial<FollowingFeedState>;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FollowingFeedTab } from '@/utility/following-feed-utils.js';
|
import type { FollowingFeedTab } from '@/types/following-feed.js';
|
||||||
import type { MenuItem } from '@/types/menu.js';
|
import type { MenuItem } from '@/types/menu.js';
|
||||||
import { getColumn, updateColumn } from '@/deck.js';
|
import { getColumn, updateColumn } from '@/deck.js';
|
||||||
import XColumn from '@/ui/deck/column.vue';
|
import XColumn from '@/ui/deck/column.vue';
|
||||||
import SkFollowingRecentNotes from '@/components/SkFollowingRecentNotes.vue';
|
import SkFollowingRecentNotes from '@/components/SkFollowingRecentNotes.vue';
|
||||||
import SkRemoteFollowersWarning from '@/components/SkRemoteFollowersWarning.vue';
|
import SkRemoteFollowersWarning from '@/components/SkRemoteFollowersWarning.vue';
|
||||||
import { createModel, createOptionsMenu, followingTab, followingTabName, followingTabIcon, followingFeedTabs } from '@/utility/following-feed-utils.js';
|
import { followingTab, followingFeedTabs } from '@/types/following-feed.js';
|
||||||
|
import { createModel, createOptionsMenu, followingTabName, followingTabIcon } from '@/utility/following-feed-utils.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { useRouter } from '@/router.js';
|
import { useRouter } from '@/router.js';
|
||||||
|
|
|
@ -4,12 +4,12 @@
|
||||||
*/
|
*/
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
|
|
||||||
export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): Array<string | string[]> | false {
|
export function checkWordMute(note: string | Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): Array<string | string[]> | false {
|
||||||
// 自分自身
|
// 自分自身
|
||||||
if (me && (note.userId === me.id)) return false;
|
if (me && typeof(note) === 'object' && (note.userId === me.id)) return false;
|
||||||
|
|
||||||
if (mutedWords.length > 0) {
|
if (mutedWords.length > 0) {
|
||||||
const text = getNoteText(note);
|
const text = typeof(note) === 'object' ? getNoteText(note) : note;
|
||||||
|
|
||||||
if (text === '') return false;
|
if (text === '') return false;
|
||||||
|
|
||||||
|
|
|
@ -7,16 +7,12 @@ import { computed } from 'vue';
|
||||||
import type { Ref, WritableComputedRef } from 'vue';
|
import type { Ref, WritableComputedRef } from 'vue';
|
||||||
import type { PageHeaderItem } from '@/types/page-header.js';
|
import type { PageHeaderItem } from '@/types/page-header.js';
|
||||||
import type { MenuItem } from '@/types/menu.js';
|
import type { MenuItem } from '@/types/menu.js';
|
||||||
|
import type { FollowingFeedTab, FollowingFeedState, FollowingFeedModel } from '@/types/following-feed.js';
|
||||||
import { deepMerge } from '@/utility/merge.js';
|
import { deepMerge } from '@/utility/merge.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { popupMenu } from '@/os.js';
|
import { popupMenu } from '@/os.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
|
import { followingTab, followersTab, mutualsTab, defaultFollowingFeedState } from '@/types/following-feed.js';
|
||||||
export const followingTab = 'following' as const;
|
|
||||||
export const mutualsTab = 'mutuals' as const;
|
|
||||||
export const followersTab = 'followers' as const;
|
|
||||||
export const followingFeedTabs = [followingTab, mutualsTab, followersTab] as const;
|
|
||||||
export type FollowingFeedTab = typeof followingFeedTabs[number];
|
|
||||||
|
|
||||||
export function followingTabName(tab: FollowingFeedTab): string;
|
export function followingTabName(tab: FollowingFeedTab): string;
|
||||||
export function followingTabName(tab: FollowingFeedTab | null | undefined): null;
|
export function followingTabName(tab: FollowingFeedTab | null | undefined): null;
|
||||||
|
@ -33,30 +29,6 @@ export function followingTabIcon(tab: FollowingFeedTab | null | undefined): stri
|
||||||
return 'ph-user-check ph-bold ph-lg';
|
return 'ph-user-check ph-bold ph-lg';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FollowingFeedModel = {
|
|
||||||
[Key in keyof FollowingFeedState]: WritableComputedRef<FollowingFeedState[Key]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FollowingFeedState = {
|
|
||||||
withNonPublic: boolean,
|
|
||||||
withQuotes: boolean,
|
|
||||||
withBots: boolean,
|
|
||||||
withReplies: boolean,
|
|
||||||
onlyFiles: boolean,
|
|
||||||
userList: FollowingFeedTab,
|
|
||||||
remoteWarningDismissed: boolean,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultFollowingFeedState: FollowingFeedState = {
|
|
||||||
withNonPublic: false,
|
|
||||||
withQuotes: false,
|
|
||||||
withBots: true,
|
|
||||||
withReplies: false,
|
|
||||||
onlyFiles: false,
|
|
||||||
userList: followingTab,
|
|
||||||
remoteWarningDismissed: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface StorageInterface {
|
interface StorageInterface {
|
||||||
readonly state: Ref<Partial<FollowingFeedState>>;
|
readonly state: Ref<Partial<FollowingFeedState>>;
|
||||||
save(updated: Partial<FollowingFeedState>): void;
|
save(updated: Partial<FollowingFeedState>): void;
|
||||||
|
|
41
packages/frontend/src/utility/parse-mutes.ts
Normal file
41
packages/frontend/src/utility/parse-mutes.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as os from '@/os';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
|
export type Mutes = (string | string[])[];
|
||||||
|
|
||||||
|
export function parseMutes(mutes: string): Mutes {
|
||||||
|
// split into lines, remove empty lines and unnecessary whitespace
|
||||||
|
const lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== '');
|
||||||
|
const outLines: Mutes = Array.from(lines);
|
||||||
|
|
||||||
|
// check each line if it is a RegExp or not
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const regexp = line.match(/^\/(.+)\/(.*)$/);
|
||||||
|
if (regexp) {
|
||||||
|
// check that the RegExp is valid
|
||||||
|
try {
|
||||||
|
new RegExp(regexp[1], regexp[2]);
|
||||||
|
// note that regex lines will not be split by spaces!
|
||||||
|
} catch (err: any) {
|
||||||
|
// invalid syntax: do not save, do not reset changed flag
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.regexpError,
|
||||||
|
text: i18n.tsx.regexpErrorDescription({ tab: 'word mute', line: i + 1 }) + '\n' + err.toString(),
|
||||||
|
});
|
||||||
|
// re-throw error so these invalid settings are not saved
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
outLines[i] = line.split(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outLines;
|
||||||
|
}
|
|
@ -245,6 +245,15 @@ _role:
|
||||||
isFromInstance: "Is from a specific instance"
|
isFromInstance: "Is from a specific instance"
|
||||||
isFromInstanceHost: "Hostname (case-insensitive)"
|
isFromInstanceHost: "Hostname (case-insensitive)"
|
||||||
isFromInstanceSubdomains: "Match subdomains"
|
isFromInstanceSubdomains: "Match subdomains"
|
||||||
|
localFollowersLessThanOrEq: "Has X or fewer local followers"
|
||||||
|
localFollowersMoreThanOrEq: "Has X or more local followers"
|
||||||
|
localFollowingLessThanOrEq: "Follows X or fewer local accounts"
|
||||||
|
localFollowingMoreThanOrEq: "Follows X or more local accounts"
|
||||||
|
remoteFollowersLessThanOrEq: "Has X or fewer remote followers"
|
||||||
|
remoteFollowersMoreThanOrEq: "Has X or more remote followers"
|
||||||
|
remoteFollowingLessThanOrEq: "Follows X or fewer remote accounts"
|
||||||
|
remoteFollowingMoreThanOrEq: "Follows X or more remote accounts"
|
||||||
|
remoteDataWarning: "This condition may be incorrect for remote users."
|
||||||
_emailUnavailable:
|
_emailUnavailable:
|
||||||
banned: "This email address is banned"
|
banned: "This email address is banned"
|
||||||
_signup:
|
_signup:
|
||||||
|
@ -543,3 +552,10 @@ enableProxyAccountDescription: "If disabled, then the proxy account will not be
|
||||||
_confirmPollEdit:
|
_confirmPollEdit:
|
||||||
title: Are you sure you want to edit this poll
|
title: Are you sure you want to edit this poll
|
||||||
text: Editing this poll will cause it to lose all previous votes
|
text: Editing this poll will cause it to lose all previous votes
|
||||||
|
|
||||||
|
wordMuteTestLabel: "Test patterns"
|
||||||
|
wordMuteTestDescription: "Enter some text here to test your word patterns. The matched words, if any, will be displayed below."
|
||||||
|
wordMuteTestTest: "Test"
|
||||||
|
wordMuteTestMatch: "Matched words: {words}"
|
||||||
|
wordMuteTestNoResults: "No results yet, enter some text and click \"Test\" to check it."
|
||||||
|
wordMuteTestNoMatch: "Text does not match any patterns."
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue