Compare commits

...

25 commits

Author SHA1 Message Date
afda33c0f4
Merge branch 'sharkey/develop-branch' into quollkey/merge-sharkey 2025-05-13 01:30:41 +09:30
Hazelnoot
cace4153e4 merge: Make muted post placeholders look clickable (resolves #502) (!1019)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1019

Closes #502

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2025-05-12 10:37:09 +00:00
Hazelnoot
f8b2e272f1 merge: Fix word mute character calculation (resolves #861) (!1018)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1018

Closes #861

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2025-05-12 10:36:17 +00:00
Hazelnoot
835e76152e merge: Add pattern checker for word mutes (resolves #1003) (!1020)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1020

Closes #1003

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2025-05-12 10:33:25 +00:00
Hazelnoot
c0c41af5f9 merge: Fix hidden hashtags showing on the explore / trending page (!1014)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1014

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2025-05-12 10:33:09 +00:00
Marie
4430c12e0e merge: Fix unique constraint error when processing a flurry of note pinning activities (!1024)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1024

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2025-05-12 10:30:44 +00:00
Hazelnoot
1eb57201b4 merge: Fix circular dependency in following feed (!1013)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1013

Approved-by: Marie <github@yuugi.dev>
Approved-by: dakkar <dakkar@thenautilus.net>
2025-05-12 09:38:43 +00:00
Hazelnoot
0f68914610 merge: Add new role conditions for local/remote followers/followees (!1002)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1002

Approved-by: Marie <github@yuugi.dev>
Approved-by: dakkar <dakkar@thenautilus.net>
2025-05-12 09:37:17 +00:00
Hazelnoot
d3a9995d0a use transaction to avoid unique constraint error when processing duplicate Add/Remove pinned note activities 2025-05-11 06:02:52 -04:00
Hazelnoot
c6ef944fc6 rename SkWordMuteTest to SkPatternTest 2025-05-10 22:49:23 -04:00
Hazelnoot
f6796a99ec add SkWordMuteTest to moderation control panel 2025-05-10 22:48:50 -04:00
Hazelnoot
32e2a07d66 extract SkWordMuteTest 2025-05-10 22:39:13 -04:00
Hazelnoot
b4bc58ae4c move parseMutes to a utility file 2025-05-10 22:36:49 -04:00
Hazelnoot
32b860c352 add UI for testing word mutes 2025-05-10 22:32:19 -04:00
Hazelnoot
9dbdb97bb5 allow checkWordMute to accept raw strings 2025-05-10 22:32:06 -04:00
Hazelnoot
f402fd3313 user appearNote in NoteSub mute placeholders 2025-05-10 21:53:05 -04:00
Hazelnoot
0e4b7c91f1 remove invisible user link from "muted note" placeholder 2025-05-10 21:52:40 -04:00
Hazelnoot
0a0f3c3387 add "clickable" styling for muted note placeholder 2025-05-10 21:50:18 -04:00
Hazelnoot
0cdb8e5b80 raise default character limit for word mutes 2025-05-10 21:44:25 -04:00
Hazelnoot
a46887d05f fix calculation of word mute 2025-05-10 21:44:10 -04:00
Hazelnoot
42d4fc9d97 refactor following feed to avoid circular dependency 2025-05-10 18:40:06 -04:00
Hazelnoot
da769846eb reset default value for new followers role conditions 2025-05-10 14:44:29 -04:00
Hazelnoot
7f3dc6066d add warning for role conditions that are dependent on remote data 2025-05-10 14:44:27 -04:00
Hazelnoot
40a73bfcbe add new role conditions for local/remote followers/followees 2025-05-10 14:44:17 -04:00
Hazelnoot
9f5c279478 don't show hidden hashtags on the trending page 2025-05-10 14:42:05 -04:00
28 changed files with 548 additions and 137 deletions

60
locales/index.d.ts vendored
View file

@ -7689,7 +7689,43 @@ export interface Locale extends ILocale {
* Match subdomains
*/
"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": {
/**
@ -12969,6 +13005,30 @@ export interface Locale extends ILocale {
*/
"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: {
[lang: string]: Locale;

View file

@ -15,6 +15,13 @@ import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
export interface FollowStats {
localFollowing: number;
localFollowers: number;
remoteFollowing: number;
remoteFollowers: number;
}
@Injectable()
export class CacheService implements OnApplicationShutdown {
public userByIdCache: MemoryKVCache<MiUser>;
@ -27,6 +34,7 @@ export class CacheService implements OnApplicationShutdown {
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: RedisKVCache<Set<string>>;
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
private readonly userFollowStatsCache = new MemoryKVCache<FollowStats>(1000 * 60 * 10); // 10 minutes
constructor(
@Inject(DI.redis)
@ -167,6 +175,18 @@ export class CacheService implements OnApplicationShutdown {
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;
}
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;
}
default:
@ -187,6 +207,52 @@ export class CacheService implements OnApplicationShutdown {
}) ?? 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
public dispose(): void {
this.redisForSub.off('message', this.onMessage);

View file

@ -10,7 +10,7 @@ import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.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 type { Config } from '@/config.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 { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import type { DataSource } from 'typeorm';
@Injectable()
export class NotePiningService {
@ -34,6 +35,9 @@ export class NotePiningService {
@Inject(DI.userNotePiningsRepository)
private userNotePiningsRepository: UserNotePiningsRepository,
@Inject(DI.db)
private readonly db: DataSource,
private userEntityService: UserEntityService,
private idService: IdService,
private roleService: RoleService,
@ -60,21 +64,23 @@ export class NotePiningService {
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) {
throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.');
}
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.');
}
if (pinings.some(pining => pining.noteId === note.id)) {
throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.');
}
if (pinings.some(pining => pining.noteId === note.id)) {
throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.');
}
await this.userNotePiningsRepository.insert({
id: this.idService.gen(),
userId: user.id,
noteId: note.id,
} as MiUserNotePining);
await tem.insert(MiUserNotePining, {
id: this.idService.gen(),
userId: user.id,
noteId: note.id,
});
});
// Deliver to remote followers
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {

View file

@ -20,6 +20,7 @@ import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import type { FollowStats } from '@/core/CacheService.js';
import type { RoleCondFormulaValue } from '@/models/Role.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
@ -92,7 +93,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canUpdateBioMedia: true,
pinLimit: 5,
antennaLimit: 5,
wordMuteLimit: 200,
wordMuteLimit: 1000,
webhookLimit: 3,
clipLimit: 10,
noteEachClipsLimit: 200,
@ -221,20 +222,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue, followStats: FollowStats): boolean {
try {
switch (value.type) {
// ~かつ~
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': {
return value.values.some(v => this.evalCond(user, roles, v));
return value.values.some(v => this.evalCond(user, roles, v, followStats));
}
// ~ではない
case 'not': {
return !this.evalCond(user, roles, value.value);
return !this.evalCond(user, roles, value.value, followStats);
}
// マニュアルロールがアサインされている
case 'roleAssignedTo': {
@ -305,6 +306,30 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
case 'followingMoreThanOrEq': {
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': {
return user.notesCount <= value.value;
@ -340,10 +365,11 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
@bindThis
public async getUserRoles(userId: MiUser['id']) {
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
const followStats = await this.cacheService.getFollowStats(userId);
const assigns = await this.getUserAssigns(userId);
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 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];
}
@ -357,12 +383,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
// 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
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 assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
if (badgeCondRoles.length > 0) {
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];
} else {
return assignedBadgeRoles;

View file

@ -147,6 +147,70 @@ type CondFormulaValueFollowingMoreThanOrEq = {
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 |
CondFormulaValueFollowingLessThanOrEq |
CondFormulaValueFollowingMoreThanOrEq |
CondFormulaValueLocalFollowersLessThanOrEq |
CondFormulaValueLocalFollowersMoreThanOrEq |
CondFormulaValueLocalFollowingLessThanOrEq |
CondFormulaValueLocalFollowingMoreThanOrEq |
CondFormulaValueRemoteFollowersLessThanOrEq |
CondFormulaValueRemoteFollowersMoreThanOrEq |
CondFormulaValueRemoteFollowingLessThanOrEq |
CondFormulaValueRemoteFollowingMoreThanOrEq |
CondFormulaValueNotesLessThanOrEq |
CondFormulaValueNotesMoreThanOrEq
);

View file

@ -58,6 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.attachedToLocalUserOnly) query.andWhere('tag.attachedLocalUsersCount != 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) {
case '+mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'DESC'); break;
case '-mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'ASC'); break;

View file

@ -330,8 +330,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
// TODO: ちゃんと数える
const length = JSON.stringify(mutedWords).length;
const length = mutedWords.reduce((sum, word) => {
const wordLength = Array.isArray(word)
? word.reduce((l, w) => l + w.length, 0)
: word.length;
return sum + wordLength;
}, 0);
if (length > limit) {
throw new ApiError(meta.errors.tooManyMutedWords);
}

View file

@ -173,23 +173,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
<MkUserName :user="appearNote.user"/>
</template>
<template #word>
{{ 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;
text-align: center;
opacity: 0.7;
cursor: pointer;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
.reactionOmitted {

View file

@ -232,9 +232,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
</div>
@ -1199,5 +1197,10 @@ function animatedMFM() {
padding: 8px;
text-align: center;
opacity: 0.7;
cursor: pointer;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
</style>

View file

@ -75,9 +75,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="note.userId" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
</MkA>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
</div>
@ -518,5 +516,10 @@ if (props.detail) {
border: 1px solid var(--MI_THEME-divider);
margin: 8px 8px 0 8px;
border-radius: var(--MI-radius-sm);
cursor: pointer;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
</style>

View file

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts">
import * as Misskey from 'misskey-js';
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 { infoImageUrl } from '@/instance.js';
import { i18n } from '@/i18n.js';

View file

@ -174,23 +174,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
<MkUserName :user="appearNote.user"/>
</template>
<template #word>
{{ 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;
text-align: center;
opacity: 0.7;
cursor: pointer;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
.reactionOmitted {

View file

@ -237,9 +237,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
</div>
@ -1273,6 +1271,11 @@ onUnmounted(() => {
padding: 8px;
text-align: center;
opacity: 0.7;
cursor: pointer;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
.badgeRoles {

View file

@ -83,9 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="note.userId" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
</MkA>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
</div>
@ -606,6 +604,11 @@ if (props.detail) {
border: 1px solid var(--MI_THEME-divider);
margin: 8px 8px 0 8px;
border-radius: var(--MI-radius-sm);
cursor: pointer;
}
.muted:hover {
background: var(--MI_THEME-buttonBg);
}
// avatar container with line

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

View file

@ -11,10 +11,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts">
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 MkInfo from '@/components/MkInfo.vue';
import { followersTab } from '@/utility/following-feed-utils.js';
import { followersTab } from '@/types/following-feed.js';
const props = defineProps<{
model: FollowingFeedModel,

View file

@ -22,6 +22,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="followersMoreThanOrEq">{{ i18n.ts._role._condition.followersMoreThanOrEq }}</option>
<option value="followingLessThanOrEq">{{ i18n.ts._role._condition.followingLessThanOrEq }}</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="notesMoreThanOrEq">{{ i18n.ts._role._condition.notesMoreThanOrEq }}</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>
</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>
<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">
<template #label>{{ i18n.ts._role._condition.isFromInstanceSubdomains }}</template>
</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>
</template>
@ -123,6 +155,14 @@ const type = computed({
if (t === 'followersMoreThanOrEq') v.value.value = 10;
if (t === 'followingLessThanOrEq') 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 === 'notesMoreThanOrEq') v.value.value = 10;
if (t === 'isFromInstance') {
@ -178,4 +218,14 @@ function removeSelf() {
border-color: var(--MI_THEME-accent);
}
}
.warningBanner {
color: var(--MI_THEME-warn);
width: 100%;
padding: 0 6px;
> i {
margin-right: 4px;
}
}
</style>

View file

@ -47,6 +47,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTextarea v-model="trustedLinkUrlPatterns">
<template #caption>{{ i18n.ts.trustedLinkUrlPatternsDescription }}</template>
</MkTextarea>
<SkPatternTest :mutedWords="trustedLinkUrlPatterns"></SkPatternTest>
<MkButton primary @click="save_trustedLinkUrlPatterns">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
@ -71,6 +74,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTextarea v-model="sensitiveWords">
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
</MkTextarea>
<SkPatternTest :mutedWords="sensitiveWords"></SkPatternTest>
<MkButton primary @click="save_sensitiveWords">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
@ -83,6 +89,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTextarea v-model="prohibitedWords">
<template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<SkPatternTest :mutedWords="prohibitedWords"></SkPatternTest>
<MkButton primary @click="save_prohibitedWords">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
@ -95,6 +104,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTextarea v-model="prohibitedWordsForNameOfUser">
<template #caption>{{ i18n.ts.prohibitedWordsForNameOfUserDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<SkPatternTest :mutedWords="prohibitedWordsForNameOfUser"></SkPatternTest>
<MkButton primary @click="save_prohibitedWordsForNameOfUser">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
@ -166,6 +178,7 @@ import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import FormLink from '@/components/form/link.vue';
import MkFolder from '@/components/MkFolder.vue';
import SkPatternTest from '@/components/SkPatternTest.vue';
const enableRegistration = ref<boolean>(false);
const emailRequiredForSignup = ref<boolean>(false);

View file

@ -33,7 +33,8 @@ import { i18n } from '@/i18n.js';
import MkSwiper from '@/components/MkSwiper.vue';
import MkPageHeader from '@/components/global/MkPageHeader.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 SkRemoteFollowersWarning from '@/components/SkRemoteFollowersWarning.vue';
import { useRouter } from '@/router.js';

View file

@ -11,6 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template>
</MkTextarea>
</div>
<SkPatternTest :mutedWords="mutedWords"></SkPatternTest>
<MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</template>
@ -19,8 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, watch } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { parseMutes } from '@/utility/parse-mutes';
import SkPatternTest from '@/components/SkPatternTest.vue';
const props = defineProps<{
muted: (string[] | string)[];
@ -30,7 +34,7 @@ const emit = defineEmits<{
(ev: 'save', value: (string[] | string)[]): void;
}>();
const render = (mutedWords) => mutedWords.map(x => {
const render = (mutedWords: (string | string[])[]) => mutedWords.map(x => {
if (Array.isArray(x)) {
return x.join(' ');
} else {
@ -46,47 +50,15 @@ watch(mutedWords, () => {
});
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 {
parsed = parseMutes(mutedWords.value);
} catch (err) {
const parsed = parseMutes(mutedWords.value);
emit('save', parsed);
changed.value = false;
} catch {
// already displayed error message in parseMutes
return;
}
emit('save', parsed);
changed.value = false;
}
</script>

View file

@ -11,10 +11,10 @@ import type { Plugin } from '@/plugin.js';
import type { DeviceKind } from '@/utility/device-kind.js';
import type { DeckProfile } from '@/deck.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 { 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 = {

View file

@ -10,11 +10,11 @@ import darkTheme from '@@/themes/d-green-lime.json5';
import { hemisphere } from '@@/js/intl-const.js';
import type { DeviceKind } from '@/utility/device-kind.js';
import type { Plugin } from '@/plugin.js';
import type { FollowingFeedState } from '@/types/following-feed.js';
import { miLocalStorage } from '@/local-storage.js';
import { Pizzax } from '@/lib/pizzax.js';
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
import { defaultFollowingFeedState } from '@/utility/following-feed-utils.js';
import type { FollowingFeedState } from '@/utility/following-feed-utils.js';
import { defaultFollowingFeedState } from '@/types/following-feed.js';
import { searchEngineMap } from '@/utility/search-engine-map.js';
/**

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

View file

@ -19,18 +19,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
import { computed, shallowRef } from 'vue';
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>;
</script>
<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 { getColumn, updateColumn } from '@/deck.js';
import XColumn from '@/ui/deck/column.vue';
import SkFollowingRecentNotes from '@/components/SkFollowingRecentNotes.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 { i18n } from '@/i18n.js';
import { useRouter } from '@/router.js';

View file

@ -4,12 +4,12 @@
*/
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) {
const text = getNoteText(note);
const text = typeof(note) === 'object' ? getNoteText(note) : note;
if (text === '') return false;

View file

@ -7,16 +7,12 @@ import { computed } from 'vue';
import type { Ref, WritableComputedRef } from 'vue';
import type { PageHeaderItem } from '@/types/page-header.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 { i18n } from '@/i18n.js';
import { popupMenu } from '@/os.js';
import { prefer } from '@/preferences.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];
import { followingTab, followersTab, mutualsTab, defaultFollowingFeedState } from '@/types/following-feed.js';
export function followingTabName(tab: FollowingFeedTab): string;
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';
}
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 {
readonly state: Ref<Partial<FollowingFeedState>>;
save(updated: Partial<FollowingFeedState>): void;

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

View file

@ -245,6 +245,15 @@ _role:
isFromInstance: "Is from a specific instance"
isFromInstanceHost: "Hostname (case-insensitive)"
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:
banned: "This email address is banned"
_signup:
@ -543,3 +552,10 @@ enableProxyAccountDescription: "If disabled, then the proxy account will not be
_confirmPollEdit:
title: Are you sure you want to edit this poll
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."