Feat add actors to meilisearch (#1048)

* initial commit

* update api docs

* adds search token versioning

* make id filterable

* isLocal -> is_local

* add iri to actor index

* update gitignore

* Merge remote-tracking branch 'origin/main' into feat-add-actors-to-meilisearch

* fix sharing for newly discovered actors

* fix ActorSearchResult type

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
Flomp
2026-06-09 21:02:05 +02:00
committed by GitHub
parent 1e2b4ea75d
commit c04a719ca6
45 changed files with 392 additions and 108 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { Actor } from "$lib/models/activitypub/actor";
import type { Actor, ActorSearchResult } from "$lib/models/activitypub/actor";
import { searchActors } from "$lib/stores/search_store";
import { show_toast } from "$lib/stores/toast_store.svelte";
import { _ } from "svelte-i18n";
@@ -32,10 +32,10 @@
return;
}
try {
const actors: Actor[] = await searchActors(q, includeSelf);
const actors: ActorSearchResult[] = await searchActors(q, includeSelf);
searchItems = actors.map((a) => ({
text: a.username,
description: `@${a.preferred_username}${a.isLocal ? "" : "@" + a.domain}`,
description: `@${a.preferred_username}${a.is_local ? "" : "@" + a.domain}`,
value: a,
icon: "user",
}));

View File

@@ -19,7 +19,7 @@
import Toggle from "./toggle.svelte";
import type { SearchItem } from "./search.svelte";
import type { SuggestionProps } from "@tiptap/suggestion";
import type { Actor } from "$lib/models/activitypub/actor";
import type { Actor, ActorSearchResult } from "$lib/models/activitypub/actor";
import { searchActors } from "$lib/stores/search_store";
import { show_toast } from "$lib/stores/toast_store.svelte";
@@ -103,13 +103,13 @@
allowToIncludeChar: true,
items: async ({ query }) => {
try {
const actors: Actor[] = await searchActors(
const actors: ActorSearchResult[] = await searchActors(
query,
true,
);
return actors.map((a) => ({
text: a.username!,
description: `@${a.preferred_username}${a.isLocal ? "" : "@" + a.domain}`,
description: `@${a.preferred_username}${a.is_local ? "" : "@" + a.domain}`,
value: a,
icon:
a.icon ||
@@ -155,7 +155,7 @@
return (_: Event, item: SearchItem) => {
props.command({
id: item.value.iri,
label: `${item.value.preferred_username}${item.value.isLocal ? "" : "@" + item.value.domain}`,
label: `${item.value.preferred_username}${item.value.is_local ? "" : "@" + item.value.domain}`,
});
};
}

View File

@@ -84,7 +84,7 @@
}
async function shareList(item: SelectItem) {
if (!item.value.isLocal && !list.public) {
if (!item.value.is_local && !list.public) {
displayShareError = true;
return;
}
@@ -162,7 +162,7 @@
alt="avatar"
/>
<p>
{`@${share.expand.actor.preferred_username}${share.expand.actor.isLocal ? "" : "@" + share.expand.actor.domain}`}
{`@${share.expand.actor.preferred_username}${share.expand.actor.is_local ? "" : "@" + share.expand.actor.domain}`}
</p>
<span
class="basis-full text-sm text-center text-gray-500"
@@ -170,13 +170,13 @@
>
<div
class="shrink-0"
class:tooltip={!share.expand.actor.isLocal}
class:tooltip={!share.expand.actor.is_local}
data-title={$_("remote-users-cannot-edit")}
>
<Select
bind:value={share.permission}
items={permissionSelectItems}
disabled={!share.expand.actor.isLocal}
disabled={!share.expand.actor.is_local}
onchange={(value) =>
updateSharePermission(share, value)}
></Select>

View File

@@ -55,7 +55,7 @@
alt="avatar"
/>
<p class="font-semibold text-base">
{`@${share.expand.actor.username}${share.expand.actor.isLocal ? "" : "@" + share.expand.actor.domain}`}
{`@${share.expand.actor.username}${share.expand.actor.is_local ? "" : "@" + share.expand.actor.domain}`}
</p>
<span class="mx-2 text-sm text-gray-500"
>{$_("can")}</span

View File

@@ -190,13 +190,13 @@
<p
class="tooltip flex justify-center"
data-title="@{log.expand.author.preferred_username}{log.expand.author
.isLocal
.is_local
? ''
: '@' + log.expand.author.domain}"
>
<a
href="/profile/@{log.expand.author.preferred_username?.toLowerCase()}{log
.expand.author.isLocal
.expand.author.is_local
? ''
: '@' + log.expand.author.domain}"
>

View File

@@ -173,7 +173,7 @@
alt="avatar"
/>
{trail.expand.author.preferred_username}{trail.expand.author
.isLocal
.is_local
? ""
: "@" + trail.expand.author.domain}
</p>

View File

@@ -86,7 +86,7 @@
return;
}
if (!item.value.isLocal && !trail.public) {
if (!item.value.is_local && !trail.public) {
displayShareError = true;
return;
}
@@ -167,11 +167,11 @@
<img
class="rounded-full w-8 aspect-square mr-2"
src={share.expand.actor.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.actor.username}&backgroundType=gradientLinear`}
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.actor.preferred_username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p>
{`@${share.expand.actor.username}${share.expand.actor.isLocal ? "" : "@" + share.expand.actor.domain}`}
{`@${share.expand.actor.username}${share.expand.actor.is_local ? "" : "@" + share.expand.actor.domain}`}
</p>
<span
class="basis-full text-sm text-gray-500 text-end"
@@ -179,13 +179,13 @@
>
<div
class="shrink-0"
class:tooltip={!share.expand.actor.isLocal}
class:tooltip={!share.expand.actor.is_local}
data-title={$_("remote-users-cannot-edit")}
>
<Select
value={share.permission}
items={permissionSelectItems}
disabled={!share.expand.actor.isLocal}
disabled={!share.expand.actor.is_local}
onchange={(value) =>
updateSharePermission(share, value)}
></Select>

View File

@@ -176,7 +176,7 @@
{#if trail.expand && trail.expand.author}
<div class="author-icon">
<img
title={`${trail.public ? $_("public") + " " : ""}${$_("by")} @${trail.expand.author.preferred_username}${trail.expand.author.isLocal ? "" : "@" + trail.expand.author.domain}`}
title={`${trail.public ? $_("public") + " " : ""}${$_("by")} @${trail.expand.author.preferred_username}${trail.expand.author.is_local ? "" : "@" + trail.expand.author.domain}`}
class="rounded-full w-5 aspect-square mx-1 inline"
src={trail.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.preferred_username}&backgroundType=gradientLinear`}

View File

@@ -15,11 +15,21 @@ export interface Actor {
icon?: string;
followers?: string;
following?: string;
isLocal: boolean;
is_local: boolean;
public_key: string;
last_fetched: string;
user?: string
expand?: {
user?: User
}
}
export interface ActorSearchResult {
id: string;
username: string;
preferred_username: string;
domain: string;
is_local: boolean;
iri: string;
icon?: string;
}

View File

@@ -1,8 +1,7 @@
import type { Actor } from "$lib/models/activitypub/actor";
import type { ActorSearchResult } from "$lib/models/activitypub/actor";
import { defaultTrailSearchAttributes, type TrailSearchResult } from "$lib/models/trail";
import { APIError } from "$lib/util/api_util";
import type { Hits, MultiSearchParams, MultiSearchResponse, MultiSearchResult, SearchParams, SearchResponse } from "meilisearch";
import type { ListResult } from "pocketbase";
export type LocationSearchResult = {
name: string;
@@ -262,16 +261,16 @@ export async function searchMulti(options: MultiSearchParams): Promise<MultiSear
return response.results
}
export async function searchActors(q: string, includeSelf: boolean = true): Promise<Actor[]> {
export async function searchActors(q: string, includeSelf: boolean = true): Promise<ActorSearchResult[]> {
try {
const r = await fetch(`/api/v1/search/actor?q=${q}&includeSelf=${includeSelf}`,)
if (!r.ok) {
return []
}
const response: ListResult<Actor> = await r.json()
const response: SearchResponse<ActorSearchResult> = await r.json()
return response.items
return response.hits
} catch (e) {
console.log(e);

View File

@@ -725,7 +725,7 @@ export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Pr
expand: {
author: {
collectionId: "activitypub_actors",
isLocal: (h.domain?.length ?? 0) == 0,
is_local: (h.domain?.length ?? 0) == 0,
id: h.author,
icon: h.author_avatar,
preferred_username: h.author_name,

View File

@@ -17,6 +17,18 @@ export function splitUsername(handle: string, localDomain?: string) {
return [user, domain]
}
export function isValidPubHandle(handle: string): boolean {
// Regex breakdown:
// ^@? - Optional leading '@'
// [a-zA-Z0-9_.-]+ - Username: alphanumeric, underscores, dots, hyphens (minimum 1 char)
// @ - Literal '@' separator
// ([a-zA-Z0-9-]+\.)+ - Domain segments (e.g., "mastodon.")
// [a-zA-Z]{2,}$ - TLD (e.g., "social", "com" - minimum 2 chars)
const activityPubRegex = /^@?[a-zA-Z0-9_.-]+@([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/;
return activityPubRegex.test(handle);
}
export function isRemoteHandle(handle: string, origin: string) {
const [, domain] = splitUsername(handle, origin);
if (!domain) {