Federation (#327)

* initial commit federation

* more federation

* more federation

* more federation

* completes follow, accept, undo

* add trail create activity

* process trail create activity

* more trail create activity

* adds missing endpoints

* adds update and delete activity

* adds comment activities

* adds activities back

* adds summit logs activities

* deletes follow counts table

* fixes migrations

* fixes migrations

* fixes migrations

* fixes migrations

* adds remote profiles

* ctd

* ctd

* we are getting closer...

* adds public summit logs

* fixes federated trails in lists

* adds remote lists

* adds list activites

* fixes lists

* adds notifications

* adds iri redirect

* fixes comments and summitlogs

* fixes follows and profiles

* adds asynchronous send

* fixes list search

* adds encryption key

* bug fixes

* fixes activity signing

* removes custon activity object types

* adds html editor

* fixes html editor

* fixes display issues on mastodon

* adds federated sharing

* finishes announcements

* fixes small summit log issues

* adds trail likes

* finalizes likes

* fixes images for komoot

* add disable federation option

* adds private profiles

* updates docs

* adds federated comments

* adds trail and comments actvitiypub routes

* adds mentions to editor

* adds mentions to trails, comments, summit logs

* updates theme

* updates theme

* update docs

* update docs

* updates docs

* fixes various frontend problems

* fixes activitypub follows api

* updates docs

* updates docs

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
Flomp
2025-06-21 10:09:22 +02:00
committed by GitHub
parent 742a6d9d30
commit 9276690d60
356 changed files with 57150 additions and 35470 deletions

View File

@@ -113,10 +113,7 @@
}
function colorKey(a: typeof currentMonthArray, i: number) {
return $_(
a[i]?.log?.expand?.trails_via_summit_logs?.at(0)?.expand?.category
?.name ?? "",
);
return $_(a[i]?.log?.expand?.trail?.expand?.category?.name ?? "");
}
function handleDateClick(date?: Date) {
@@ -170,7 +167,10 @@
</div>
</div>
<style>
<style lang="postcss">
@reference "tailwindcss";
@reference "../../../css/app.css";
.calendar-weekday {
font-weight: 600;
}

View File

@@ -46,7 +46,10 @@
<div class="my-4" id="slider" bind:this={sliderContainer}></div>
<style>
<style lang="postcss">
@reference "tailwindcss";
@reference "../../../css/app.css";
:global(.noUi-horizontal) {
height: 6px;
}

View File

@@ -32,7 +32,7 @@
{#if open}
<div
class="fixed drawer-overlay h-screen w-screen backdrop-blur-md z-50 bg-gray-500/50 cursor-pointer"
class="fixed drawer-overlay h-screen w-screen backdrop-blur-md z-50 bg-gray-500 opacity-50 cursor-pointer"
onclick={() => (open = false)}
onkeydown={(e) => {
if (e.key == "Escape") open = false;

View File

@@ -29,28 +29,50 @@
e.preventDefault();
isOpen = !isOpen;
if (isOpen) {
await tick();
const toggleRect = dropdownToggleElement.getBoundingClientRect();
const toggleRect = dropdownToggleElement.getBoundingClientRect();
const dropdownRect = dropdownElement.getBoundingClientRect();
dropdownElement.style.visibility = "";
// Viewport dimensions
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - toggleRect.bottom;
const spaceAbove = toggleRect.top;
const spaceBelowViewport = viewportHeight - toggleRect.bottom;
const spaceAboveViewport = toggleRect.top;
if (
// Container dimensions
const scrollContainer =
dropdownElement.closest(".scroll-x-only") ?? document.body;
const containerRect = scrollContainer.getBoundingClientRect();
const spaceBelowContainer =
containerRect.bottom - toggleRect.bottom;
const spaceAboveContainer = toggleRect.top - containerRect.top;
// Effective space
const spaceBelow = Math.min(
spaceBelowViewport,
spaceBelowContainer,
);
const spaceAbove = Math.min(
spaceAboveViewport,
spaceAboveContainer,
);
// Determine direction
const openUpward =
spaceBelow < dropdownRect.height &&
spaceAbove > dropdownRect.height
) {
spaceAbove > dropdownRect.height;
if (openUpward) {
dropdownElement.classList.remove("rounded-b-xl");
dropdownElement.classList.add("rounded-t-xl");
dropdownElement.style.top = `${-8-dropdownRect.height}px`;
dropdownElement.style.top = `${-8 - dropdownRect.height}px`;
} else {
dropdownElement.classList.remove("rounded-t-xl");
dropdownElement.classList.add("rounded-b-xl");
dropdownElement.style.top = `${toggleRect.height +8}px`;
dropdownElement.style.top = `${toggleRect.height + 8}px`;
}
}
}

View File

@@ -0,0 +1,501 @@
<script lang="ts">
import { Editor, mergeAttributes } from "@tiptap/core";
import { Link } from "@tiptap/extension-link";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import { Underline } from "@tiptap/extension-underline";
import StarterKit from "@tiptap/starter-kit";
import { mount, onDestroy, onMount, unmount, untrack } from "svelte";
import { _ } from "svelte-i18n";
import { z, ZodError } from "zod";
import { type DropdownItem } from "./dropdown.svelte";
import Modal from "./modal.svelte";
import {
default as DropdownList,
default as SearchList,
} from "./search_list.svelte";
import { type SelectItem } from "./select.svelte";
import TextField from "./text_field.svelte";
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 { searchActors } from "$lib/stores/search_store";
import { show_toast } from "$lib/stores/toast_store.svelte";
let element: HTMLElement;
let editor: Editor | undefined = $state();
let modal: Modal;
interface Props {
value?: string;
label?: string;
error?: string | string[] | null;
placeholder?: string;
extraClasses?: string;
searchListPosition?: string;
}
let {
value = $bindable(""),
label = "",
error = "",
placeholder = "",
extraClasses = "",
searchListPosition = "absolute"
}: Props = $props();
const fontSizes: SelectItem[] = [
{ text: $_("paragraph"), value: "p" },
{ text: `${$_("heading")} 1`, value: "h1" },
{ text: `${$_("heading")} 2`, value: "h2" },
{ text: `${$_("heading")} 3`, value: "h3" },
{ text: `${$_("heading")} 4`, value: "h4" },
{ text: `${$_("heading")} 5`, value: "h5" },
];
let currentFontSize: string = $state("p");
let linkURL: string = $state("");
let linkURLError: string = $state("");
let linkText: string = $state("");
let openLinkInNewTab: boolean = $state(false);
$effect(() => {
value;
untrack(() => {
if (value !== editor?.getHTML()) {
editor?.commands.setContent(value);
}
});
});
onMount(() => {
editor = new Editor({
element: element,
extensions: [
StarterKit,
Underline,
Placeholder.configure({
placeholder: placeholder,
}),
Mention.configure({
HTMLAttributes: {
class: "mention",
},
renderHTML({ node, options }) {
return [
"a",
mergeAttributes(
{ href: `/profile/@${options.HTMLAttributes["data-label"]}` },
options.HTMLAttributes,
),
options.renderText?.({
options: options,
node,
}),
];
},
suggestion: {
allowToIncludeChar: true,
items: async ({ query }) => {
try {
const actors: Actor[] = await searchActors(
query,
true,
);
return actors.map((a) => ({
text: a.preferred_username!,
description: `@${a.username}${a.isLocal ? "" : "@" + a.domain}`,
value: a,
icon:
a.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${a.username}&backgroundType=gradientLinear`,
}));
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: "Error during search",
});
return [];
}
},
render: () => {
let component: DropdownList;
const componentState: any = $state({
items: [],
id: "mention-list",
});
function updatePosition({
clientRect,
}: SuggestionProps) {
const searchListElement =
document.getElementById("mention-list");
if (!clientRect || !searchListElement) return;
const box = clientRect();
if (!box) {
return;
}
searchListElement.style.position = searchListPosition;
searchListElement.style.top = `${box.bottom + window.scrollY + 4}px`;
searchListElement.style.left = `${box.left + window.scrollX}px`;
searchListElement.style.zIndex = "1001";
}
function onActorClick(props: SuggestionProps) {
return (_: Event, item: SearchItem) => {
props.command({
id: item.value.iri,
label: `${item.value.username}${item.value.isLocal ? "" : "@" + item.value.domain}`,
});
};
}
return {
onStart: (props) => {
props.clientRect;
componentState.items = props.items;
componentState.onclick =
onActorClick(props);
component = mount(SearchList, {
target: element,
props: componentState,
});
updatePosition(props);
},
onUpdate(props) {
componentState.items = props.items;
componentState.onclick =
onActorClick(props);
updatePosition(props);
},
onKeyDown(props) {
if (props.event.key === "Escape") {
this.onExit?.({} as unknown as any)
return true;
}
return false
},
onExit() {
unmount(component);
},
};
},
},
}),
Link.configure({
openOnClick: false,
autolink: true,
defaultProtocol: "https",
protocols: ["http", "https"],
isAllowedUri: (url, ctx) => {
try {
const parsedUrl = url.includes(":")
? new URL(url)
: new URL(`${ctx.defaultProtocol}://${url}`);
if (!ctx.defaultValidate(parsedUrl.href)) {
return false;
}
const disallowedProtocols = [
"ftp",
"file",
"mailto",
];
const protocol = parsedUrl.protocol.replace(
":",
"",
);
if (disallowedProtocols.includes(protocol)) {
return false;
}
const allowedProtocols = ctx.protocols.map((p) =>
typeof p === "string" ? p : p.scheme,
);
if (!allowedProtocols.includes(protocol)) {
return false;
}
return true;
} catch {
return false;
}
},
}),
],
content: value,
onTransaction: ({ editor: newEditor }) => {
// force re-render so `editor.isActive` works as expected
editor = undefined;
editor = newEditor;
},
onUpdate: (props) => {
value = editor?.getHTML() ?? "";
},
editorProps: {
attributes: {
class: `prose dark:prose-invert text-content bg-input-background border border-input-border rounded-md p-3 resize-none transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 ${extraClasses}`,
},
},
onSelectionUpdate: ({ editor }) => {
if (editor.isActive("paragraph")) {
currentFontSize = "p";
return;
}
for (let i = 1; i <= 5; i++) {
if (editor.isActive("heading", { level: i })) {
currentFontSize = "h" + i;
return;
}
}
},
});
});
onDestroy(() => {
if (editor) {
editor.destroy();
}
});
function openLinkModal() {
linkURL = editor?.getAttributes("link").href ?? "";
openLinkInNewTab = editor?.getAttributes("link").target === "_blank";
if (linkURL) {
editor?.chain().focus().extendMarkRange("link").run();
}
linkText =
editor?.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
"",
) ?? "";
modal.openModal();
}
function setLink() {
linkURLError = "";
if (linkURL === "") {
linkURLError = $_("required");
return;
}
try {
z.string().url().parse(linkURL);
editor
?.chain()
.focus()
.insertContent({
type: "text",
text: linkText || linkURL,
marks: [
{
type: "link",
attrs: {
href: linkURL,
target: openLinkInNewTab ? "_blank" : null,
},
},
],
})
.run();
modal.closeModal();
} catch (e) {
if (
e instanceof ZodError &&
e.errors[0].code === "invalid_string"
) {
linkURLError = $_("not-a-valid-url");
}
console.error(e);
}
}
function unsetLink() {
editor?.chain().focus().extendMarkRange("link").unsetLink().run();
modal.closeModal();
}
</script>
<div id="editor-wrapper">
{#if label.length}
<p class="text-sm font-medium mb-1">
{label}
</p>
{/if}
<!-- Toolbar -->
<div class="flex flex-wrap items-center py-2 gap-y-2">
<div class="mr-2">
<!-- <Select
items={fontSizes}
bind:value={currentFontSize}
onchange={(value: string) => {
if (value.startsWith("p")) {
editor?.chain().focus().setParagraph().run();
} else {
editor
?.chain()
.focus()
.setHeading({
level: parseInt(value.substring(1)) as Level,
})
.run();
}
}}
></Select> -->
</div>
<div class="flex gap-2 border-r border-input-border pr-2">
<button
type="button"
class="btn-icon"
onclick={() => editor?.chain().focus().toggleBold().run()}
class:ring-2={editor?.isActive("bold")}
aria-label="Bold"
>
<i class="fas fa-bold"></i>
</button>
<button
type="button"
class="btn-icon"
onclick={() => editor?.chain().focus().toggleItalic().run()}
class:ring-2={editor?.isActive("italic")}
aria-label="Italic"
>
<i class="fas fa-italic"></i>
</button>
<button
type="button"
class="btn-icon"
onclick={() => editor?.chain().focus().toggleUnderline().run()}
class:ring-2={editor?.isActive("underline")}
aria-label="Underline"
>
<i class="fas fa-underline"></i>
</button>
</div>
<div class="flex gap-1 border-r border-input-border px-2">
<button
type="button"
class="btn-icon"
onclick={() => editor?.chain().focus().toggleBulletList().run()}
class:ring-2={editor?.isActive("bulletList")}
aria-label="Bullet List"
>
<i class="fas fa-list-ul"></i>
</button>
<button
type="button"
class="btn-icon"
onclick={() =>
editor?.chain().focus().toggleOrderedList().run()}
class:ring-2={editor?.isActive("orderedList")}
aria-label="Numbered List"
>
<i class="fas fa-list-ol"></i>
</button>
</div>
<div class="flex gap-1 border-r border-input-border px-2">
<button
type="button"
class="btn-icon"
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
class:ring-2={editor?.isActive("blockquote")}
aria-label="Quote"
>
<i class="fas fa-quote-right"></i>
</button>
</div>
<div class="flex gap-1 px-2">
<button
type="button"
class="btn-icon"
onclick={() => openLinkModal()}
class:ring-2={editor?.isActive("link")}
aria-label="Link"
>
<i class="fas fa-link"></i>
</button>
</div>
</div>
<div bind:this={element}></div>
{#if error}
<span class="editor-error text-xs text-red-400">
{error instanceof Array ? $_(error[0]) : error}
</span>
{/if}
</div>
<Modal
id="editor-modal"
title={"Insert/edit link"}
size="md:min-w-lg"
bind:this={modal}
>
{#snippet content()}
<TextField label={"URL"} bind:value={linkURL} error={linkURLError}
></TextField>
<TextField label={$_("text")} bind:value={linkText}></TextField>
<Toggle
label={$_("open-in-new-tab", { values: { n: 2 } })}
bind:value={openLinkInNewTab}
></Toggle>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
{#if editor?.getAttributes("link").href}
<button
type="button"
class="btn-secondary shrink-0"
onclick={() => unsetLink()}
aria-label="Link"
>
<i class="fas fa-link-slash"></i>
<span>{$_("unlink")}</span>
</button>
<div class="basis-full"></div>
{/if}
<button
type="button"
class="btn-secondary"
onclick={() => modal.closeModal()}>{$_("cancel")}</button
>
<button
class="btn-primary"
type="button"
name="save"
onclick={() => setLink()}>{$_("save")}</button
>
</div>
{/snippet}
</Modal>
<style>
:global(.ProseMirror p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
color: #adb5bd;
pointer-events: none;
height: 0;
}
</style>

View File

@@ -42,7 +42,7 @@
{id}
tabindex="-1"
aria-hidden="true"
class="w-full {size} max-h-full rounded-xl text-content"
class="{size} max-h-full rounded-xl text-content"
>
<!-- Modal content -->
<div class="bg-background shadow rounded-xl">
@@ -73,8 +73,9 @@
</div>
</dialog>
<style>
<style lang="postcss">
@reference "tailwindcss";
dialog::backdrop {
@apply bg-gray-500/50;
@apply bg-gray-500 opacity-50;
}
</style>

View File

@@ -8,9 +8,10 @@
</script>
<script lang="ts">
import { fade } from "svelte/transition";
import TextField from "./text_field.svelte";
import type { Snippet } from "svelte";
import { fade } from "svelte/transition";
import SearchList from "./search_list.svelte";
import TextField from "./text_field.svelte";
interface Props {
maxSearchLength?: number;
@@ -118,34 +119,6 @@
></TextField>
{#if dropDownOpen}
<ul
class="menu absolute bg-menu-background border border-input-border rounded-xl shadow-md overflow-x-hidden overflow-y-scroll max-h-72 w-full"
class:none={!dropDownOpen}
style="z-index: 1001"
>
{#each items as item}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<li
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
tabindex="0"
onmousedown={(e) => handleItemClick(e, item)}
onkeydown={(e) => handleItemClick(e, item)}
>
{#if prepend}{@render prepend({ item })}{:else}
<i class="fa fa-{item.icon} basis-8 shrink-0"></i>
{/if}
<div>
<p>{item.text}</p>
{#if item.description}
<p class="text-sm text-gray-500">
{item.description}
</p>
{/if}
</div>
</li>
{/each}
</ul>
<SearchList {items} extraClasses="w-full" {prepend} onclick={handleItemClick}></SearchList>
{/if}
</div>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import { isURL } from "$lib/util/file_util";
import type { Snippet } from "svelte";
import type { SearchItem } from "./search.svelte";
import { _ } from "svelte-i18n";
interface Props {
id?: string;
items?: SearchItem[];
element?: HTMLUListElement;
onclick?: (e: Event, item: SearchItem) => void;
prepend?: Snippet<[any]>;
extraClasses?: string;
}
let {
id,
items = [],
element = $bindable(),
onclick,
prepend,
extraClasses,
}: Props = $props();
</script>
<ul
{id}
class="menu absolute bg-menu-background border border-input-border rounded-xl shadow-md overflow-x-hidden overflow-y-scroll max-h-72 {extraClasses}"
style="z-index: 1001"
>
{#if items.length == 0}
<li
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
>
{$_("no-results")}
</li>
{/if}
{#each items as item}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<li
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
tabindex="0"
onmousedown={(e) => onclick?.(e, item)}
onkeydown={(e) => onclick?.(e, item)}
>
{#if prepend}{@render prepend({ item })}
{:else if isURL(item.icon)}
<img
class="rounded-full w-8 mr-4 aspect-square"
src={item.icon}
alt="avatar"
/>
{:else}
<i class="fa fa-{item.icon} basis-8 shrink-0"></i>
{/if}
<div>
<p>{item.text}</p>
{#if item.description}
<p class="text-sm text-gray-500">
{item.description}
</p>
{/if}
</div>
</li>
{/each}
</ul>

View File

@@ -37,7 +37,7 @@
{/if}
<select
{name}
class="block bg-input-background h-10 px-4 border-r-8 border-transparent outline outline-1 outline-input-border rounded-md focus:outline-input-border-focus transition-colors"
class="block bg-input-background h-10 px-4 border-r-8 border-transparent outline-1 outline-input-border rounded-md focus:outline-input-border-focus transition-colors"
class:text-gray-500={disabled}
{disabled}
bind:value

View File

@@ -0,0 +1,53 @@
<div class="animate-pulse space-y-4 p-4">
<!-- Table Header Skeleton -->
<div class="flex items-center justify-between gap-4 px-2">
<div class="h-5 w-20 bg-menu-item-background-focus rounded"></div>
<div class="h-5 w-16 bg-menu-item-background-focus rounded"></div>
<div class="h-5 w-24 bg-menu-item-background-focus rounded"></div>
<div class="h-5 w-24 bg-menu-item-background-focus rounded"></div>
<div class="h-5 w-16 bg-menu-item-background-focus rounded"></div>
<div class="h-5 w-12 bg-menu-item-background-focus rounded"></div>
<div class="h-5 w-8 bg-menu-item-background-focus rounded"></div>
</div>
<!-- Repeatable Skeleton Rows -->
<div class="space-y-2">
{#each { length: 5 } as _, index}
<!-- Row 1 -->
<div class="flex items-center justify-between gap-4 px-2">
<!-- Image placeholder -->
<div
class="h-10 w-10 bg-menu-item-background-focus rounded-lg"
></div>
<!-- Date -->
<div
class="h-4 w-20 bg-menu-item-background-focus rounded"
></div>
<!-- Distance -->
<div
class="h-4 w-16 bg-menu-item-background-focus rounded"
></div>
<!-- Elevation gain -->
<div
class="h-4 w-24 bg-menu-item-background-focus rounded"
></div>
<!-- Elevation loss -->
<div
class="h-4 w-24 bg-menu-item-background-focus rounded"
></div>
<!-- Duration -->
<div
class="h-4 w-12 bg-menu-item-background-focus rounded"
></div>
<!-- Author -->
<div
class="h-6 w-6 bg-menu-item-background-focus rounded-full"
></div>
<!-- Map icon -->
<div
class="h-5 w-5 bg-menu-item-background-focus rounded-md"
></div>
</div>
{/each}
</div>
</div>

View File

@@ -44,13 +44,16 @@
});
export function set(value: number) {
sliderContainer.noUiSlider.set(value)
sliderContainer.noUiSlider.set(value);
}
</script>
<div class="my-4" id="slider" bind:this={sliderContainer}></div>
<style>
<style lang="postcss">
@reference "tailwindcss";
@reference "../../../css/app.css";
:global(.noUi-target) {
@apply border border-input-border shadow-none;
}

View File

@@ -5,7 +5,7 @@
{#each toastStore.toasts as t, i}
<div
class="fixed px-4 py-3 shadow-xl rounded-xl border right-4 bg-white text-gray-500 flex items-center max-w-md"
class="fixed px-4 py-3 shadow-xl rounded-xl border border-input-border right-4 bg-white text-gray-500 flex items-center max-w-md"
style="z-index: 1001; bottom: {16 + i * 72}px"
in:fade={{ duration: 250 }}
out:fade={{ duration: 250 }}