Implementation of an instances wide antenna source. (#9604)

This PR contains new source for antenna posts, which is a list of instance hostnames to process all posts from.

Using this mode, a user can filter for keywords on an instance wide basis.

This change includes a new antenna source called `instances` and a new database column in the `antenna` table called `instances` to store the instance names.

On the antenna editor, there's also an "Add an instance" finder dialog to allow users to search through the known instance hostnames.

Co-authored-by: Kaity A <supakaity@blahaj.zone>
Reviewed-on: https://codeberg.org/calckey/calckey/pulls/9604
Co-authored-by: Kaity A <supakaity@noreply.codeberg.org>
Co-committed-by: Kaity A <supakaity@noreply.codeberg.org>
This commit is contained in:
Kaity A 2023-02-12 01:20:17 +00:00 committed by Kainoa Kanter
parent 7091f889ee
commit 2b030a0a8c
13 changed files with 255 additions and 12 deletions

View file

@ -32,6 +32,7 @@ uploading: "Uploading..."
save: "Save" save: "Save"
users: "Users" users: "Users"
addUser: "Add a user" addUser: "Add a user"
addInstance: "Add an instance"
favorite: "Add to bookmarks" favorite: "Add to bookmarks"
favorites: "Bookmarks" favorites: "Bookmarks"
unfavorite: "Remove from bookmarks" unfavorite: "Remove from bookmarks"
@ -160,6 +161,7 @@ proxyAccount: "Proxy account"
proxyAccountDescription: "A proxy account is an account that acts as a remote follower for users under certain conditions. For example, when a user adds a remote user to the list, the remote user's activity will not be delivered to the instance if no local user is following that user, so the proxy account will follow instead." proxyAccountDescription: "A proxy account is an account that acts as a remote follower for users under certain conditions. For example, when a user adds a remote user to the list, the remote user's activity will not be delivered to the instance if no local user is following that user, so the proxy account will follow instead."
host: "Host" host: "Host"
selectUser: "Select a user" selectUser: "Select a user"
selectInstance: "Select an instance"
recipient: "Recipient(s)" recipient: "Recipient(s)"
annotation: "Comments" annotation: "Comments"
federation: "Federation" federation: "Federation"
@ -197,6 +199,7 @@ muteAndBlock: "Mutes and Blocks"
mutedUsers: "Muted users" mutedUsers: "Muted users"
blockedUsers: "Blocked users" blockedUsers: "Blocked users"
noUsers: "There are no users" noUsers: "There are no users"
noInstances: "There are no instances"
editProfile: "Edit profile" editProfile: "Edit profile"
noteDeleteConfirm: "Are you sure you want to delete this post?" noteDeleteConfirm: "Are you sure you want to delete this post?"
pinLimitExceeded: "You cannot pin any more posts" pinLimitExceeded: "You cannot pin any more posts"
@ -363,6 +366,7 @@ notifyAntenna: "Notify about new posts"
withFileAntenna: "Only posts with files" withFileAntenna: "Only posts with files"
enableServiceworker: "Enable Push-Notifications for your Browser" enableServiceworker: "Enable Push-Notifications for your Browser"
antennaUsersDescription: "List one username per line" antennaUsersDescription: "List one username per line"
antennaInstancesDescription: "List one instance host per line"
caseSensitive: "Case sensitive" caseSensitive: "Case sensitive"
withReplies: "Include replies" withReplies: "Include replies"
connectedTo: "Following account(s) are connected" connectedTo: "Following account(s) are connected"
@ -1301,6 +1305,7 @@ _antennaSources:
users: "Posts from specific users" users: "Posts from specific users"
userList: "Posts from a specified list of users" userList: "Posts from a specified list of users"
userGroup: "Posts from users in a specified group" userGroup: "Posts from users in a specified group"
instances: "Posts from all users on an instance"
_weekday: _weekday:
sunday: "Sunday" sunday: "Sunday"
monday: "Monday" monday: "Monday"

View file

@ -0,0 +1,17 @@
export class AntennaInstances1676093997212 {
name = 'AntennaInstances1676093997212'
async up(queryRunner) {
await queryRunner.query(`ALTER TYPE "antenna_src_enum" ADD VALUE 'instances'`);
await queryRunner.query(`ALTER TABLE "antenna" ADD "instances" jsonb NOT NULL DEFAULT '[]'`);
}
async down(queryRunner) {
await queryRunner.query(`DELETE FROM "antenna" WHERE "src" = 'instances'`);
await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "instances"`);
await queryRunner.query(`CREATE TYPE "public"."antenna_src_enum_old" AS ENUM('home', 'all', 'users', 'list', 'group')`);
await queryRunner.query(`ALTER TABLE "antenna" ALTER COLUMN "src" TYPE "public"."antenna_src_enum_old" USING "src"::"text"::"public"."antenna_src_enum_old"`);
await queryRunner.query(`DROP TYPE "public"."antenna_src_enum"`);
await queryRunner.query(`ALTER TYPE "public"."antenna_src_enum_old" RENAME TO "antenna_src_enum"`);
}
}

View file

@ -79,7 +79,14 @@ export async function checkHitAntenna(
getFullApAccount(noteUser.username, noteUser.host).toLowerCase(), getFullApAccount(noteUser.username, noteUser.host).toLowerCase(),
) )
) )
return false; return false;
} else if (antenna.src === "instances") {
const instances = antenna.instances
.filter(x => x !== "")
.map(host => {
return host.toLowerCase();
});
if (!instances.includes(noteUser.host?.toLowerCase() ?? "")) return false;
} }
const keywords = antenna.keywords const keywords = antenna.keywords

View file

@ -40,8 +40,8 @@ export class Antenna {
}) })
public name: string; public name: string;
@Column('enum', { enum: ['home', 'all', 'users', 'list', 'group'] }) @Column('enum', { enum: ['home', 'all', 'users', 'list', 'group', 'instances'] })
public src: "home" | "all" | "users" | "list" | "group"; public src: "home" | "all" | "users" | "list" | "group" | "instances";
@Column({ @Column({
...id(), ...id(),
@ -73,6 +73,11 @@ export class Antenna {
}) })
public users: string[]; public users: string[];
@Column('jsonb', {
default: [],
})
public instances: string[];
@Column('jsonb', { @Column('jsonb', {
default: [], default: [],
}) })

View file

@ -25,6 +25,7 @@ export const AntennaRepository = db.getRepository(Antenna).extend({
userListId: antenna.userListId, userListId: antenna.userListId,
userGroupId: userGroupJoining ? userGroupJoining.userGroupId : null, userGroupId: userGroupJoining ? userGroupJoining.userGroupId : null,
users: antenna.users, users: antenna.users,
instances: antenna.instances,
caseSensitive: antenna.caseSensitive, caseSensitive: antenna.caseSensitive,
notify: antenna.notify, notify: antenna.notify,
withReplies: antenna.withReplies, withReplies: antenna.withReplies,

View file

@ -52,7 +52,7 @@ export const packedAntennaSchema = {
type: "string", type: "string",
optional: false, optional: false,
nullable: false, nullable: false,
enum: ["home", "all", "users", "list", "group"], enum: ["home", "all", "users", "list", "group", "instances"],
}, },
userListId: { userListId: {
type: "string", type: "string",
@ -76,6 +76,16 @@ export const packedAntennaSchema = {
nullable: false, nullable: false,
}, },
}, },
instances: {
type: "array",
optional: false,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false,
},
},
caseSensitive: { caseSensitive: {
type: "boolean", type: "boolean",
optional: false, optional: false,

View file

@ -37,7 +37,7 @@ export const paramDef = {
type: "object", type: "object",
properties: { properties: {
name: { type: "string", minLength: 1, maxLength: 100 }, name: { type: "string", minLength: 1, maxLength: 100 },
src: { type: "string", enum: ["home", "all", "users", "list", "group"] }, src: { type: "string", enum: ["home", "all", "users", "list", "group", "instances"] },
userListId: { type: "string", format: "misskey:id", nullable: true }, userListId: { type: "string", format: "misskey:id", nullable: true },
userGroupId: { type: "string", format: "misskey:id", nullable: true }, userGroupId: { type: "string", format: "misskey:id", nullable: true },
keywords: { keywords: {
@ -64,6 +64,12 @@ export const paramDef = {
type: "string", type: "string",
}, },
}, },
instances: {
type: "array",
items: {
type: "string",
},
},
caseSensitive: { type: "boolean" }, caseSensitive: { type: "boolean" },
withReplies: { type: "boolean" }, withReplies: { type: "boolean" },
withFile: { type: "boolean" }, withFile: { type: "boolean" },
@ -75,6 +81,7 @@ export const paramDef = {
"keywords", "keywords",
"excludeKeywords", "excludeKeywords",
"users", "users",
"instances",
"caseSensitive", "caseSensitive",
"withReplies", "withReplies",
"withFile", "withFile",
@ -118,6 +125,7 @@ export default define(meta, paramDef, async (ps, user) => {
keywords: ps.keywords, keywords: ps.keywords,
excludeKeywords: ps.excludeKeywords, excludeKeywords: ps.excludeKeywords,
users: ps.users, users: ps.users,
instances: ps.instances,
caseSensitive: ps.caseSensitive, caseSensitive: ps.caseSensitive,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withFile: ps.withFile, withFile: ps.withFile,

View file

@ -43,7 +43,7 @@ export const paramDef = {
properties: { properties: {
antennaId: { type: "string", format: "misskey:id" }, antennaId: { type: "string", format: "misskey:id" },
name: { type: "string", minLength: 1, maxLength: 100 }, name: { type: "string", minLength: 1, maxLength: 100 },
src: { type: "string", enum: ["home", "all", "users", "list", "group"] }, src: { type: "string", enum: ["home", "all", "users", "list", "group", "instances"] },
userListId: { type: "string", format: "misskey:id", nullable: true }, userListId: { type: "string", format: "misskey:id", nullable: true },
userGroupId: { type: "string", format: "misskey:id", nullable: true }, userGroupId: { type: "string", format: "misskey:id", nullable: true },
keywords: { keywords: {
@ -70,6 +70,12 @@ export const paramDef = {
type: "string", type: "string",
}, },
}, },
instances: {
type: "array",
items: {
type: "string",
},
},
caseSensitive: { type: "boolean" }, caseSensitive: { type: "boolean" },
withReplies: { type: "boolean" }, withReplies: { type: "boolean" },
withFile: { type: "boolean" }, withFile: { type: "boolean" },
@ -82,6 +88,7 @@ export const paramDef = {
"keywords", "keywords",
"excludeKeywords", "excludeKeywords",
"users", "users",
"instances",
"caseSensitive", "caseSensitive",
"withReplies", "withReplies",
"withFile", "withFile",
@ -131,6 +138,7 @@ export default define(meta, paramDef, async (ps, user) => {
keywords: ps.keywords, keywords: ps.keywords,
excludeKeywords: ps.excludeKeywords, excludeKeywords: ps.excludeKeywords,
users: ps.users, users: ps.users,
instances: ps.instances,
caseSensitive: ps.caseSensitive, caseSensitive: ps.caseSensitive,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withFile: ps.withFile, withFile: ps.withFile,

View file

@ -102,10 +102,13 @@ export default define(meta, paramDef, async (ps, me) => {
if (typeof ps.blocked === "boolean") { if (typeof ps.blocked === "boolean") {
const meta = await fetchMeta(true); const meta = await fetchMeta(true);
if (ps.blocked) { if (ps.blocked) {
if (meta.blockedHosts.length === 0) {
return [];
}
query.andWhere("instance.host IN (:...blocks)", { query.andWhere("instance.host IN (:...blocks)", {
blocks: meta.blockedHosts, blocks: meta.blockedHosts,
}); });
} else { } else if (meta.blockedHosts.length > 0) {
query.andWhere("instance.host NOT IN (:...blocks)", { query.andWhere("instance.host NOT IN (:...blocks)", {
blocks: meta.blockedHosts, blocks: meta.blockedHosts,
}); });

View file

@ -0,0 +1,153 @@
<template>
<XModalWindow
ref="dialogEl"
:with-ok-button="true"
:ok-button-disabled="selected == null"
@click="cancel()"
@close="cancel()"
@ok="ok()"
@closed="$emit('closed')"
>
<template #header>{{ i18n.ts.selectInstance }}</template>
<div class="mehkoush">
<div class="form">
<MkInput v-model="hostname" :autofocus="true" @update:modelValue="search">
<template #label>{{ i18n.ts.instance }}</template>
</MkInput>
</div>
<div v-if="hostname != ''" class="result" :class="{ hit: instances.length > 0 }">
<div v-if="instances.length > 0" class="instances">
<div v-for="item in instances" :key="item.id" class="instance" :class="{ selected: selected && selected.id === item.id }" @click="selected = item" @dblclick="ok()">
<div class="body">
<img class="icon" :src="item.iconUrl ?? '/client-assets/dummy.png'" aria-hidden="true"/>
<span class="name">{{ item.host }}</span>
</div>
</div>
</div>
<div v-else class="empty">
<span>{{ i18n.ts.noInstances }}</span>
</div>
</div>
</div>
</XModalWindow>
</template>
<script lang="ts" setup>
import MkInput from '@/components/form/input.vue';
import XModalWindow from '@/components/MkModalWindow.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { Instance } from 'calckey-js/built/entities';
const emit = defineEmits<{
(ev: 'ok', selected: Instance): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
let hostname = $ref('');
let instances: Instance[] = $ref([]);
let selected: Instance | null = $ref(null);
let dialogEl = $ref<InstanceType<typeof XModalWindow>>();
const search = () => {
if (hostname === '') {
instances = [];
return;
}
os.api('federation/instances', {
host: hostname,
limit: 10,
blocked: false,
suspended: false,
sort: '+pubSub',
}).then(_instances => {
instances = _instances.map(x => ({
id: x.id,
host: x.host,
iconUrl: x.iconUrl,
} as Instance));
});
};
const ok = () => {
if (selected == null) return;
emit('ok', selected);
dialogEl?.close();
};
const cancel = () => {
emit('cancel');
dialogEl?.close();
};
</script>
<style lang="scss" scoped>
.mehkoush {
margin: var(--marginFull) 0;
> .form {
padding: 0 var(--root-margin);
}
> .result {
display: flex;
flex-direction: column;
overflow: auto;
height: 100%;
&.result.hit {
padding: 0;
}
> .instances {
flex: 1;
overflow: auto;
padding: 8px 0;
> .instance {
display: flex;
align-items: center;
padding: 8px var(--root-margin);
font-size: 14px;
&:hover {
background: var(--X7);
}
&.selected {
background: var(--accent);
color: #fff;
}
> * {
pointer-events: none;
user-select: none;
}
> .body {
padding: 0 8px;
width: 100%;
> .name {
display: block;
font-weight: bold;
}
> .icon {
width: 16px;
height: 16px;
margin-right: 8px;
float: left;
}
}
}
}
> .empty {
opacity: 0.7;
text-align: center;
}
}
}
</style>

View file

@ -545,6 +545,21 @@ export async function selectUser() {
}); });
} }
export async function selectInstance(): Promise<Misskey.entities.Instance> {
return new Promise((resolve, reject) => {
popup(
defineAsyncComponent(() => import("@/components/MkInstanceSelectDialog.vue")),
{},
{
ok: (instance) => {
resolve(instance);
},
},
"closed",
);
});
}
export async function selectDriveFile(multiple: boolean) { export async function selectDriveFile(multiple: boolean) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup( popup(

View file

@ -5,7 +5,6 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { inject } from 'vue';
import XAntenna from './editor.vue'; import XAntenna from './editor.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
@ -19,6 +18,7 @@ let draft = $ref({
userListId: null, userListId: null,
userGroupId: null, userGroupId: null,
users: [], users: [],
instances: [],
keywords: [], keywords: [],
excludeKeywords: [], excludeKeywords: [],
withReplies: false, withReplies: false,
@ -31,10 +31,6 @@ function onAntennaCreated() {
router.push('/my/antennas'); router.push('/my/antennas');
} }
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata({ definePageMetadata({
title: i18n.ts.manageAntennas, title: i18n.ts.manageAntennas,
icon: 'ph-flying-saucer-bold ph-lg', icon: 'ph-flying-saucer-bold ph-lg',

View file

@ -11,6 +11,7 @@
<option value="users">{{ i18n.ts._antennaSources.users }}</option> <option value="users">{{ i18n.ts._antennaSources.users }}</option>
<!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>--> <!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>-->
<!--<option value="group">{{ i18n.ts._antennaSources.userGroup }}</option>--> <!--<option value="group">{{ i18n.ts._antennaSources.userGroup }}</option>-->
<option value="instances">{{ i18n.ts._antennaSources.instances }}</option>
</MkSelect> </MkSelect>
<MkSelect v-if="src === 'list'" v-model="userListId" class="_formBlock"> <MkSelect v-if="src === 'list'" v-model="userListId" class="_formBlock">
<template #label>{{ i18n.ts.userList }}</template> <template #label>{{ i18n.ts.userList }}</template>
@ -24,6 +25,10 @@
<template #label>{{ i18n.ts.users }}</template> <template #label>{{ i18n.ts.users }}</template>
<template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template> <template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
</MkTextarea> </MkTextarea>
<MkTextarea v-else-if="src === 'instances'" v-model="instances" class="_formBlock">
<template #label>{{ i18n.ts.instances }}</template>
<template #caption>{{ i18n.ts.antennaInstancesDescription }} <button class="_textButton" @click="addInstance">{{ i18n.ts.addInstance }}</button></template>
</MkTextarea>
<MkSwitch v-model="withReplies" class="_formBlock">{{ i18n.ts.withReplies }}</MkSwitch> <MkSwitch v-model="withReplies" class="_formBlock">{{ i18n.ts.withReplies }}</MkSwitch>
<MkTextarea v-model="keywords" class="_formBlock"> <MkTextarea v-model="keywords" class="_formBlock">
<template #label>{{ i18n.ts.antennaKeywords }}</template> <template #label>{{ i18n.ts.antennaKeywords }}</template>
@ -70,6 +75,7 @@ let src: string = $ref(props.antenna.src);
let userListId: any = $ref(props.antenna.userListId); let userListId: any = $ref(props.antenna.userListId);
let userGroupId: any = $ref(props.antenna.userGroupId); let userGroupId: any = $ref(props.antenna.userGroupId);
let users: string = $ref(props.antenna.users.join('\n')); let users: string = $ref(props.antenna.users.join('\n'));
let instances: string = $ref(props.antenna.instances.join('\n'));
let keywords: string = $ref(props.antenna.keywords.map(x => x.join(' ')).join('\n')); let keywords: string = $ref(props.antenna.keywords.map(x => x.join(' ')).join('\n'));
let excludeKeywords: string = $ref(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n')); let excludeKeywords: string = $ref(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
let caseSensitive: boolean = $ref(props.antenna.caseSensitive); let caseSensitive: boolean = $ref(props.antenna.caseSensitive);
@ -103,6 +109,7 @@ async function saveAntenna() {
notify, notify,
caseSensitive, caseSensitive,
users: users.trim().split('\n').map(x => x.trim()), users: users.trim().split('\n').map(x => x.trim()),
instances: instances.trim().split('\n').map(x => x.trim()),
keywords: keywords.trim().split('\n').map(x => x.trim().split(' ')), keywords: keywords.trim().split('\n').map(x => x.trim().split(' ')),
excludeKeywords: excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')), excludeKeywords: excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')),
}; };
@ -139,6 +146,14 @@ function addUser() {
users = users.trim(); users = users.trim();
}); });
} }
function addInstance() {
os.selectInstance().then(instance => {
instances = instances.trim();
instances += '\n' + instance.host;
instances = instances.trim();
});
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>