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 653c71dad5
commit 4ca445b587
13 changed files with 255 additions and 12 deletions

View File

@ -32,6 +32,7 @@ uploading: "Uploading..."
save: "Save"
users: "Users"
addUser: "Add a user"
addInstance: "Add an instance"
favorite: "Add to bookmarks"
favorites: "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."
host: "Host"
selectUser: "Select a user"
selectInstance: "Select an instance"
recipient: "Recipient(s)"
annotation: "Comments"
federation: "Federation"
@ -197,6 +199,7 @@ muteAndBlock: "Mutes and Blocks"
mutedUsers: "Muted users"
blockedUsers: "Blocked users"
noUsers: "There are no users"
noInstances: "There are no instances"
editProfile: "Edit profile"
noteDeleteConfirm: "Are you sure you want to delete this post?"
pinLimitExceeded: "You cannot pin any more posts"
@ -363,6 +366,7 @@ notifyAntenna: "Notify about new posts"
withFileAntenna: "Only posts with files"
enableServiceworker: "Enable Push-Notifications for your Browser"
antennaUsersDescription: "List one username per line"
antennaInstancesDescription: "List one instance host per line"
caseSensitive: "Case sensitive"
withReplies: "Include replies"
connectedTo: "Following account(s) are connected"
@ -1301,6 +1305,7 @@ _antennaSources:
users: "Posts from specific users"
userList: "Posts from a specified list of users"
userGroup: "Posts from users in a specified group"
instances: "Posts from all users on an instance"
_weekday:
sunday: "Sunday"
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(),
)
)
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

View File

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

View File

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

View File

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

View File

@ -37,7 +37,7 @@ export const paramDef = {
type: "object",
properties: {
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 },
userGroupId: { type: "string", format: "misskey:id", nullable: true },
keywords: {
@ -64,6 +64,12 @@ export const paramDef = {
type: "string",
},
},
instances: {
type: "array",
items: {
type: "string",
},
},
caseSensitive: { type: "boolean" },
withReplies: { type: "boolean" },
withFile: { type: "boolean" },
@ -75,6 +81,7 @@ export const paramDef = {
"keywords",
"excludeKeywords",
"users",
"instances",
"caseSensitive",
"withReplies",
"withFile",
@ -118,6 +125,7 @@ export default define(meta, paramDef, async (ps, user) => {
keywords: ps.keywords,
excludeKeywords: ps.excludeKeywords,
users: ps.users,
instances: ps.instances,
caseSensitive: ps.caseSensitive,
withReplies: ps.withReplies,
withFile: ps.withFile,

View File

@ -43,7 +43,7 @@ export const paramDef = {
properties: {
antennaId: { type: "string", format: "misskey:id" },
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 },
userGroupId: { type: "string", format: "misskey:id", nullable: true },
keywords: {
@ -70,6 +70,12 @@ export const paramDef = {
type: "string",
},
},
instances: {
type: "array",
items: {
type: "string",
},
},
caseSensitive: { type: "boolean" },
withReplies: { type: "boolean" },
withFile: { type: "boolean" },
@ -82,6 +88,7 @@ export const paramDef = {
"keywords",
"excludeKeywords",
"users",
"instances",
"caseSensitive",
"withReplies",
"withFile",
@ -131,6 +138,7 @@ export default define(meta, paramDef, async (ps, user) => {
keywords: ps.keywords,
excludeKeywords: ps.excludeKeywords,
users: ps.users,
instances: ps.instances,
caseSensitive: ps.caseSensitive,
withReplies: ps.withReplies,
withFile: ps.withFile,

View File

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

View File

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

View File

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