feat: Add settings page to configure account and preferences

This commit is contained in:
Jesse Wierzbinski 2024-06-18 20:16:28 -10:00
parent 633ff184e3
commit 1691daa000
No known key found for this signature in database
21 changed files with 687 additions and 183 deletions

View file

@ -24,8 +24,8 @@
- [x] Media uploads - [x] Media uploads
- [x] WCAG 2.2 AAA compliance - [x] WCAG 2.2 AAA compliance
- Testing is automated and may not catch all issues, please report any accessibility issues you find. - Testing is automated and may not catch all issues, please report any accessibility issues you find.
- [ ] Settings - [x] Settings
- [ ] Profile editing - [x] Profile editing
### Browser Support ### Browser Support

View file

@ -1,8 +1,8 @@
<template> <template>
<button v-bind="$props" type="button" :disabled="loading" <button v-bind="$props" type="button" :disabled="loading"
:class="['relative isolate text-base/6 font-semibold px-[calc(theme(spacing[3.5])-1px)] py-[calc(theme(spacing[2.5])-1px)] sm:px-[calc(theme(spacing.3)-1px)] sm:py-[calc(theme(spacing[1.5])-1px)] sm:text-sm/6 focus:outline-none focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-[--btn-bg] before:absolute before:inset-0 before:-z-10 before:rounded-[calc(theme(borderRadius.lg)-1px)] before:shadow before:hidden after:absolute after:-z-10 after:-inset-px after:rounded-md before:disabled:shadow-none after:disabled:shadow-none text-white cursor-default rounded-md duration-200 hover:shadow disabled:opacity-70 content-none disabled:cursor-not-allowed shadow-sm bg-[--btn-bg] before:bg-[--btn-bg] after:active:bg-[--btn-hover-overlay] after:hover:bg-[--btn-hover-overlay]', loading && '[&>*]:invisible']"> :class="['relative isolate text-base/6 font-semibold px-[calc(theme(spacing[3.5])-1px)] py-[calc(theme(spacing[2.5])-1px)] sm:px-[calc(theme(spacing.3)-1px)] sm:py-[calc(theme(spacing[1.5])-1px)] sm:text-sm/6 focus:outline-none focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-[--btn-bg] before:absolute before:inset-0 before:-z-10 before:rounded-[calc(theme(borderRadius.lg)-1px)] before:shadow before:hidden after:absolute after:-z-10 after:-inset-px after:rounded-md before:disabled:shadow-none after:disabled:shadow-none text-white cursor-default rounded-md duration-200 hover:shadow disabled:opacity-70 content-none disabled:cursor-not-allowed shadow-sm bg-[--btn-bg] before:bg-[--btn-bg] after:active:bg-[--btn-hover-overlay] after:hover:bg-[--btn-hover-overlay]', loading && '[&>*]:invisible']">
<div v-if="loading" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 !visible"> <div v-if="loading" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 !visible size-5">
<iconify-icon icon="tabler:loader-2" height="1.25rem" width="1.25rem" class="animate-spin" /> <iconify-icon icon="tabler:loader-2" height="none" class="animate-spin size-5" />
</div> </div>
<slot /> <slot />
</button> </button>

View file

@ -6,20 +6,8 @@
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
</div> </div>
<div class="px-6 pb-4 pt-5"> <div class="px-6 pb-4 pt-5">
<div class="pb-2 relative"> <InputsRichTextbox v-model:model-content="content" @paste="handlePaste" :disabled="loading"
<textarea :disabled="loading" ref="textarea" v-model="content" :placeholder="chosenSplash" :placeholder="chosenSplash" :max-characters="characterLimit" class="focus:!ring-0 max-h-[70dvh]" />
@paste="handlePaste"
class="resize-none min-h-48 prose prose-invert max-h-[70dvh] w-full p-0 focus:!ring-0 !ring-none !border-none !outline-none placeholder:text-zinc-500 bg-transparent appearance-none focus:!border-none focus:!outline-none disabled:cursor-not-allowed"
aria-label="Compose your message"></textarea>
<div :class="['absolute bottom-0 right-0 p-2 text-gray-400 font-semibold text-xs', remainingCharacters < 0 && 'text-red-500']"
aria-live="polite">
{{ remainingCharacters }}
</div>
<ComposerEmojiSuggestbox :textarea="textarea" v-if="!!currentlyBeingTypedEmoji"
:currently-typing-emoji="currentlyBeingTypedEmoji" @autocomplete="autocompleteEmoji" />
<ComposerMentionSuggestbox :textarea="textarea" v-if="!!currentlyBeingTypedMention"
:currently-typing-mention="currentlyBeingTypedMention" @autocomplete="autocompleteMention" />
</div>
<!-- Content warning textbox --> <!-- Content warning textbox -->
<div v-if="cw" class="mb-4"> <div v-if="cw" class="mb-4">
<input type="text" v-model="cwContent" placeholder="Add a content warning" <input type="text" v-model="cwContent" placeholder="Add a content warning"
@ -50,27 +38,23 @@
<ButtonsPrimary :loading="loading" @click="send" class="ml-auto rounded-full" <ButtonsPrimary :loading="loading" @click="send" class="ml-auto rounded-full"
:disabled="!canSubmit || loading"> :disabled="!canSubmit || loading">
<span>{{ <span>{{
respondingType === "edit" ? "Edit!" : "Send!" respondingType === "edit" ? "Edit!" : "Send!"
}}</span> }}</span>
</ButtonsPrimary> </ButtonsPrimary>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { char, createRegExp, exactly } from "magic-regexp";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import type { Instance } from "~/types/mastodon/instance"; import type { Instance } from "~/types/mastodon/instance";
import type { Status } from "~/types/mastodon/status"; import type { Status } from "~/types/mastodon/status";
import { OverlayScrollbarsComponent } from "#imports"; import { OverlayScrollbarsComponent } from "#imports";
import type FileUploader from "./file-uploader.vue"; import type FileUploader from "./file-uploader.vue";
const textarea = ref<HTMLTextAreaElement | undefined>(undefined);
const uploader = ref<InstanceType<typeof FileUploader> | undefined>(undefined); const uploader = ref<InstanceType<typeof FileUploader> | undefined>(undefined);
const { input: content } = useTextareaAutosize({
element: textarea,
});
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys(); const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
const content = ref("");
const respondingTo = ref<Status | null>(null); const respondingTo = ref<Status | null>(null);
const respondingType = ref<"reply" | "quote" | "edit" | null>(null); const respondingType = ref<"reply" | "quote" | "edit" | null>(null);
const identity = useCurrentIdentity(); const identity = useCurrentIdentity();
@ -80,41 +64,11 @@ const markdown = ref(true);
const splashes = useConfig().COMPOSER_SPLASHES; const splashes = useConfig().COMPOSER_SPLASHES;
const chosenSplash = ref(splashes[Math.floor(Math.random() * splashes.length)]); const chosenSplash = ref(splashes[Math.floor(Math.random() * splashes.length)]);
const currentlyBeingTypedEmoji = computed(() => {
const match = content.value?.match(partiallyTypedEmojiValidator);
return match ? match.at(-1)?.replace(":", "") ?? "" : null;
});
const currentlyBeingTypedMention = computed(() => {
const match = content.value?.match(partiallyTypedMentionValidator);
return match ? match.at(-1)?.replace("@", "") ?? "" : null;
});
const openFilePicker = () => { const openFilePicker = () => {
uploader.value?.openFilePicker(); uploader.value?.openFilePicker();
}; };
const autocompleteEmoji = (emoji: string) => {
// Replace the end of the string with the emoji
content.value = content.value?.replace(
createRegExp(
exactly(":"),
exactly(currentlyBeingTypedEmoji.value ?? "").notBefore(char),
),
`:${emoji}:`,
);
};
const autocompleteMention = (mention: string) => {
// Replace the end of the string with the mention
content.value = content.value?.replace(
createRegExp(
exactly("@"),
exactly(currentlyBeingTypedMention.value ?? "").notBefore(char),
),
`@${mention} `,
);
};
const files = ref< const files = ref<
{ {
id: string; id: string;
@ -170,7 +124,6 @@ onMounted(() => {
respondingType.value = "reply"; respondingType.value = "reply";
if (note.account.id !== identity.value?.account.id) if (note.account.id !== identity.value?.account.id)
content.value = `@${note.account.acct} `; content.value = `@${note.account.acct} `;
textarea.value?.focus();
}); });
useListen("composer:quote", (note: Status) => { useListen("composer:quote", (note: Status) => {
@ -178,7 +131,6 @@ onMounted(() => {
respondingType.value = "quote"; respondingType.value = "quote";
if (note.account.id !== identity.value?.account.id) if (note.account.id !== identity.value?.account.id)
content.value = `@${note.account.acct} `; content.value = `@${note.account.acct} `;
textarea.value?.focus();
}); });
useListen("composer:edit", async (note: Status) => { useListen("composer:edit", async (note: Status) => {
@ -199,7 +151,6 @@ onMounted(() => {
respondingType.value = "edit"; respondingType.value = "edit";
content.value = source.data.text; content.value = source.data.text;
cwContent.value = source.data.spoiler_text; cwContent.value = source.data.spoiler_text;
textarea.value?.focus();
} }
loading.value = false; loading.value = false;
@ -285,7 +236,4 @@ const send = async () => {
const characterLimit = computed( const characterLimit = computed(
() => props.instance?.configuration.statuses.max_characters ?? 0, () => props.instance?.configuration.statuses.max_characters ?? 0,
); );
const remainingCharacters = computed(
() => characterLimit.value - (content.value?.length ?? 0),
);
</script> </script>

View file

@ -8,19 +8,18 @@
<div <div
class="mt-4 text-center flex flex-col justify-center sm:mt-0 sm:text-left bg-dark-800 py-2 px-4 rounded grow ring-1 ring-white/10"> class="mt-4 text-center flex flex-col justify-center sm:mt-0 sm:text-left bg-dark-800 py-2 px-4 rounded grow ring-1 ring-white/10">
<p class="text-sm font-medium text-gray-300">Welcome back,</p> <p class="text-sm font-medium text-gray-300">Welcome back,</p>
<p class="text-xl font-bold text-gray-50 sm:text-2xl line-clamp-1" <p class="text-xl font-bold text-gray-50 sm:text-2xl line-clamp-1" v-html="display_name"></p>
v-html="useParsedContent(identity.account.display_name, []).value"></p>
</div> </div>
</div> </div>
<!-- <div class="mt-5 flex justify-center sm:mt-0">
<ButtonsSecondary @click="useEvent('composer:open')">
Compose
</ButtonsSecondary>
</div> -->
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const identity = useCurrentIdentity(); const identity = useCurrentIdentity();
const settings = useSettings();
const { display_name } = useParsedAccount(
computed(() => identity.value?.account),
settings,
);
</script> </script>

View file

@ -0,0 +1,85 @@
<template>
<div class="relative">
<textarea v-bind="$attrs" ref="textarea" v-model="content"
class="resize-none min-h-48 prose prose-invert w-full p-0 !ring-none !border-none !outline-none placeholder:text-zinc-500 bg-transparent appearance-none focus:!border-none focus:!outline-none disabled:cursor-not-allowed"
aria-label="Compose your message" :autofocus="true"></textarea>
<div v-if="maxCharacters"
:class="['absolute bottom-0 right-0 p-2 text-gray-300 font-semibold text-xs', remainingCharacters < 0 && 'text-red-500']"
aria-live="polite">
{{ remainingCharacters }}
</div>
<ComposerEmojiSuggestbox :textarea="textarea" v-if="!!currentlyBeingTypedEmoji"
:currently-typing-emoji="currentlyBeingTypedEmoji" @autocomplete="autocompleteEmoji" />
<ComposerMentionSuggestbox :textarea="textarea" v-if="!!currentlyBeingTypedMention"
:currently-typing-mention="currentlyBeingTypedMention" @autocomplete="autocompleteMention" />
</div>
</template>
<script lang="ts" setup>
import { char, createRegExp, exactly } from "magic-regexp";
defineOptions({
inheritAttrs: false,
});
const props = defineProps<{
maxCharacters?: number;
modelContent: string;
}>();
const emit = defineEmits<{
"update:modelContent": [value: string];
}>();
const textarea = ref<HTMLTextAreaElement | undefined>(undefined);
const { input: content } = useTextareaAutosize({
element: textarea,
input: props.modelContent,
});
watch(
() => props.modelContent,
(value) => {
content.value = value;
},
);
watch(content, (newValue) => {
emit("update:modelContent", newValue);
});
const remainingCharacters = computed(
() =>
(props.maxCharacters ?? Number.POSITIVE_INFINITY) -
(content.value?.length ?? 0),
);
const currentlyBeingTypedEmoji = computed(() => {
const match = content.value?.match(partiallyTypedEmojiValidator);
return match ? match.at(-1)?.replace(":", "") ?? "" : null;
});
const currentlyBeingTypedMention = computed(() => {
const match = content.value?.match(partiallyTypedMentionValidator);
return match ? match.at(-1)?.replace("@", "") ?? "" : null;
});
const autocompleteEmoji = (emoji: string) => {
// Replace the end of the string with the emoji
content.value = content.value?.replace(
createRegExp(
exactly(":"),
exactly(currentlyBeingTypedEmoji.value ?? "").notBefore(char),
),
`:${emoji}:`,
);
};
const autocompleteMention = (mention: string) => {
// Replace the end of the string with the mention
content.value = content.value?.replace(
createRegExp(
exactly("@"),
exactly(currentlyBeingTypedMention.value ?? "").notBefore(char),
),
`@${mention} `,
);
};
</script>

View file

@ -0,0 +1,88 @@
<template>
<!-- OIDC linked accounts manager -->
<div class="w-full ring-1 ring-white/5 pb-5 bg-dark-800 rounded overflow-hidden">
<div class="px-4 py-4">
<h3 class="font-semibold text-gray-300 text-xl">Linked accounts</h3>
</div>
<div class="px-4 py-3">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2 gap-4">
<div v-for="provider of ssoConfig?.providers" :key="provider.id"
class="flex items-center justify-between p-4 bg-dark-700 rounded">
<div class="flex items-center gap-4">
<AvatarsCentered :src="provider.icon" :alt="provider.name" class="h-8 w-8" />
<span class="font-semibold text-gray-300">{{ provider.name }}</span>
</div>
<div>
<ButtonsPrimary :loading="loading" v-if="!linkedProviders?.find(p => p.id === provider.id)"
@click="link(provider.id)">
<span>Link</span>
</ButtonsPrimary>
<ButtonsSecondary :loading="loading" v-else @click="unlink(provider.id)">
<span>Unlink</span>
</ButtonsSecondary>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { ResponseError } from "@lysand-org/client";
const client = useClient();
const ssoConfig = useSSOConfig();
const linkedProviders = useLinkedSSO(client);
const loading = ref(false);
const link = async (providerId: string) => {
loading.value = true;
try {
const output = await client.value.post<{
link: string;
}>("/api/v1/sso", {
issuer: providerId,
});
window.location.href = output.data.link;
} catch (error) {
const e = error as ResponseError<{ error: string }>;
useEvent("notification:new", {
title: "Failed to link account",
message: e.response.data.error,
type: "error",
});
}
loading.value = false;
};
const unlink = async (providerId: string) => {
loading.value = true;
try {
await client.value.delete<void>(`/api/v1/sso/${providerId}`);
useEvent("notification:new", {
title: "Account unlinked",
type: "success",
});
linkedProviders.value = linkedProviders.value.filter(
(p) => p.id !== providerId,
);
} catch (error) {
const e = error as ResponseError<{ error: string }>;
useEvent("notification:new", {
title: "Failed to unlink account",
message: e.response.data.error,
type: "error",
});
}
loading.value = false;
};
</script>

View file

@ -0,0 +1,120 @@
<template>
<div
class="w-full md:px-8 px-4 py-4 bg-dark-700 grid justify-center lg:grid-cols-[minmax(auto,_36rem)_1fr] grid-cols-1 gap-4">
<form class="w-full ring-1 ring-inset ring-white/5 pb-5 bg-dark-800 rounded overflow-hidden"
@submit.prevent="save">
<AvatarsCentered :src="account?.header" :alt="`${account?.acct}'s header image'`"
class="w-full aspect-[8/3] border-b border-white/10 bg-dark-700 !rounded-none" />
<div class="flex items-start justify-between px-4 py-3">
<AvatarsCentered :src="account?.avatar" :alt="`${account?.acct}'s avatar'`"
class="h-32 w-32 -mt-[4.5rem] z-10 shrink-0 rounded ring-2 ring-dark-800" />
</div>
<div class="mt-2 px-4">
<InputsText @input="displayName = ($event.target as HTMLInputElement).value" :value="displayName"
aria-label="Display name" :disabled="loading" />
<div class="mt-2 grid grid-cols-[auto_1fr] items-center gap-x-2">
<iconify-icon icon="tabler:at" width="none" class="size-6" aria-hidden="true" />
<InputsText @input="acct = ($event.target as HTMLInputElement).value" :value="acct"
aria-label="Username" :disabled="loading" />
</div>
<p class="text-gray-300 text-xs mt-2">
Changing your username will break all links to your profile.
</p>
</div>
<div class="mt-3 px-4">
<InputsRichTextbox v-model:model-content="note" :max-characters="noteCharacterLimit" :disabled="loading"
class="rounded ring-white/10 ring-2 focus:ring-primary-600 px-4 py-2 max-h-[40dvh] max-w-full" />
</div>
<div class="px-4 mt-4 grid grid-cols-2 gap-2">
<ButtonsPrimary class="w-full" type="submit" :loading="loading">
<span>Save</span>
</ButtonsPrimary>
<ButtonsSecondary class="w-full" @click="revert" type="button" :loading="loading">
<span>Revert</span>
</ButtonsSecondary>
</div>
</form>
<div>
<SettingsOidc />
</div>
</div>
</template>
<script lang="ts" setup>
import type { ResponseError } from "@lysand-org/client";
const identity = useCurrentIdentity();
const account = computed(() => identity.value?.account);
const note = ref(account.value?.source?.note ?? "");
const displayName = ref(account.value?.display_name ?? "");
const acct = ref(account.value?.acct ?? "");
const noteCharacterLimit = computed(
() => identity.value?.instance.configuration.statuses.max_characters ?? 0,
);
const client = useClient();
const loading = ref(false);
const revert = () => {
useEvent("notification:new", {
title: "Reverted to current bio",
type: "success",
});
note.value = account.value?.source?.note ?? "";
};
const save = async () => {
const changedData = {
display_name:
displayName.value === account.value?.display_name
? undefined
: displayName.value,
username: acct.value === account.value?.acct ? undefined : acct.value,
note:
note.value === account.value?.source?.note ? undefined : note.value,
};
if (
Object.values(changedData).filter((v) => v !== undefined).length === 0
) {
useEvent("notification:new", {
title: "No changes",
type: "error",
});
return;
}
loading.value = true;
try {
const { data } = await client.value.updateCredentials(
Object.fromEntries(
Object.entries(changedData).filter(([, v]) => v !== undefined),
),
);
useEvent("notification:new", {
title: "Profile updated",
type: "success",
});
if (identity.value) identity.value.account = data;
} catch (e) {
const error = e as ResponseError<{ error: string }>;
useEvent("notification:new", {
title: "Failed to update profile",
message: error.response.data.error,
type: "error",
});
}
loading.value = false;
};
</script>

View file

@ -0,0 +1,47 @@
<template>
<div class="w-full px-8 py-4 bg-dark-700 hover:bg-dark-500 duration-100">
<Switch.Root v-model:checked="checked" class="grid grid-cols-[1fr_auto] gap-x-4"
@click="setting.notImplemented ? $event.preventDefault() : undefined"
v-if="setting.type === SettingType.Boolean" @update:checked="c => checked = c">
<Switch.Label :data-disabled="setting.notImplemented ? '' : undefined"
class="row-start-1 select-none text-base/6 data-[disabled]:opacity-50 sm:text-sm/6 text-white font-semibold">
{{
setting.title
}}</Switch.Label>
<p v-if="setting.notImplemented" class="text-xs mt-1 row-start-3 text-red-300 font-semibold">Not implemented
</p>
<p v-else :data-disabled="setting.notImplemented ? '' : undefined"
class="text-base/6 row-start-2 data-[disabled]:opacity-50 sm:text-sm/6 text-gray-300">{{
setting.description }}
</p>
<Switch.Control :data-disabled="setting.notImplemented ? '' : undefined"
:data-checked="checked ? '' : undefined"
class="group col-start-2 relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8 transition duration-0 ease-in-out data-[changing]:duration-200 forced-colors:outline forced-colors:[--switch-bg:Highlight] ring-1 ring-inset bg-white/5 ring-white/15 data-[checked]:bg-[--switch-bg] data-[checked]:ring-[--switch-bg-ring] focus:outline-none focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-blue-500 hover:data-[checked]:ring-[--switch-bg-ring] hover:ring-white/25 data-[disabled]:bg-zinc-200 data-[disabled]:data-[checked]:bg-zinc-200 data-[disabled]:opacity-50 data-[disabled]:bg-white/15 data-[disabled]:data-[checked]:bg-white/15 data-[disabled]:data-[checked]:ring-white/15 [--switch-bg-ring:transparent] [--switch-bg:theme(colors.primary.600/25%)] [--switch-shadow:theme(colors.black/10%)] [--switch:white] [--switch-ring:theme(colors.white/10%)]">
<Switch.Thumb
class="pointer-events-none relative inline-block size-[1.125rem] rounded-full sm:size-3.5 translate-x-0 transition duration-200 ease-in-out border border-transparent bg-white shadow ring-1 ring-black/5 group-data-[checked]:bg-[--switch] group-data-[checked]:shadow-[--switch-shadow] group-data-[checked]:ring-[--switch-ring] group-data-[checked]:translate-x-4 sm:group-data-[checked]:translate-x-3 group-data-[disabled]:group-data-[checked]:bg-white group-data-[disabled]:group-data-[checked]:shadow group-data-[disabled]:group-data-[checked]:ring-black/5" />
</Switch.Control>
<Switch.HiddenInput />
</Switch.Root>
<div v-else class="grid grid-cols-[1fr_auto] gap-x-4">
<h4 class="row-start-1 select-none text-base/6 sm:text-sm/6 text-white font-semibold">{{ setting.title }}
</h4>
<p class="text-xs mt-1 row-start-3 text-red-300 font-semibold">Not implemented</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { Switch } from "@ark-ui/vue";
import { type Setting, type SettingIds, SettingType } from "~/settings";
const props = defineProps<{
setting: Setting;
}>();
const checked = ref(!!props.setting.value);
const setting = useSetting(props.setting.id as SettingIds);
watch(checked, (c) => {
setting.value.value = c;
});
</script>

View file

@ -12,7 +12,7 @@
<div class="flex flex-row items-center justify-between w-full"> <div class="flex flex-row items-center justify-between w-full">
<div class="font-semibold text-gray-200 text-sm line-clamp-1 break-all"> <div class="font-semibold text-gray-200 text-sm line-clamp-1 break-all">
{{ {{
currentIdentity.account.display_name }} currentIdentity.account.display_name }}
</div> </div>
</div> </div>
<span class="text-gray-400 text-xs line-clamp-1 break-all w-full"> <span class="text-gray-400 text-xs line-clamp-1 break-all w-full">
@ -44,13 +44,13 @@
<div class="flex flex-row items-center justify-between w-full"> <div class="flex flex-row items-center justify-between w-full">
<div class="font-semibold text-gray-200 line-clamp-1 break-all"> <div class="font-semibold text-gray-200 line-clamp-1 break-all">
{{ {{
identity.account.display_name }} identity.account.display_name }}
</div> </div>
</div> </div>
<span class="text-gray-400 text-sm line-clamp-1 break-all w-full"> <span class="text-gray-400 text-sm line-clamp-1 break-all w-full">
@{{ @{{
identity.account.acct identity.account.acct
}} }}
</span> </span>
</div> </div>
<button data-part="item" <button data-part="item"
@ -60,6 +60,20 @@
</button> </button>
</div> </div>
</Menu.Item> </Menu.Item>
<Menu.Item value="" v-if="currentIdentity">
<NuxtLink href="/settings" class="w-full">
<div class="rounded text-left flex flex-row gap-x-2 hover:scale-[95%]">
<div
class="shrink-0 size-12 border-dashed border-white/20 border flex items-center justify-center rounded">
<iconify-icon icon="tabler:adjustments" class="size-6 text-gray-200" width="none" />
</div>
<div
class="flex flex-col items-start font-semibold p-1 justify-around text-sm text-gray-300 grow overflow-hidden">
Settings
</div>
</div>
</NuxtLink>
</Menu.Item>
<Menu.Item value=""> <Menu.Item value="">
<button @click="$emit('signIn')" class="w-full"> <button @click="$emit('signIn')" class="w-full">
<div class="rounded text-left flex flex-row gap-x-2 hover:scale-[95%]"> <div class="rounded text-left flex flex-row gap-x-2 hover:scale-[95%]">

View file

@ -34,6 +34,20 @@
<span class="pr-28 line-clamp-1">Register</span> <span class="pr-28 line-clamp-1">Register</span>
</ButtonsBase> </ButtonsBase>
</NuxtLink> </NuxtLink>
<NuxtLink href="/settings" v-if="identity">
<button @click="$emit('signIn')" class="w-full overflow-hidden">
<div class="rounded text-left flex flex-row items-center gap-x-2 hover:scale-[95%]">
<div
class="shrink-0 size-12 border-dashed border-white/20 border flex items-center justify-center rounded">
<iconify-icon icon="tabler:adjustments" class="size-6 text-gray-200" width="none" />
</div>
<span
class="line-clamp-1 font-semibold p-1 justify-around text-sm text-gray-300 grow overflow-hidden">
Settings
</span>
</div>
</button>
</NuxtLink>
<h3 v-if="identity" <h3 v-if="identity"
class="font-semibold text-gray-300 text-xs uppercase opacity-0 group-hover:opacity-100 duration-200"> class="font-semibold text-gray-300 text-xs uppercase opacity-0 group-hover:opacity-100 duration-200">
Posts</h3> Posts</h3>

View file

@ -0,0 +1,33 @@
<template>
<Tabs.Root v-model="tab">
<Tabs.List class="flex flex-row p-4 gap-4 bg-dark-800 relative ring-1 ring-white/5 overflow-x-auto">
<Tabs.Trigger :value="page" v-for="page of SettingPages" :as-child="true">
<ButtonsBase class="capitalize hover:bg-white/5">
{{ page }}
</ButtonsBase>
</Tabs.Trigger>
<Tabs.Indicator class="h-1 bg-gray-300 w-[--width] top-0 rounded-b" />
</Tabs.List>
<Tabs.Content :value="page" v-for="page of SettingPages">
<slot :name="page" />
</Tabs.Content>
</Tabs.Root>
</template>
<script lang="ts" setup>
import { Tabs } from "@ark-ui/vue";
import { SettingPages } from "~/settings";
const tab = ref<SettingPages>(
(window.location.hash.slice(1) as SettingPages) || SettingPages.Account,
);
// Update page hash when tab changes
watch(
tab,
(value) => {
window.location.hash = value;
},
{ immediate: true },
);
</script>

View file

@ -174,6 +174,7 @@ useListen("composer:send-edit", (note) => {
const client = useClient(); const client = useClient();
const identity = useCurrentIdentity(); const identity = useCurrentIdentity();
const settings = useSettings();
const { const {
loaded, loaded,
note: outputtedNote, note: outputtedNote,
@ -185,7 +186,7 @@ const {
reblog, reblog,
isReply, isReply,
reblogDisplayName, reblogDisplayName,
} = useNoteData(noteRef, client); } = useNoteData(noteRef, client, settings);
const openBlank = (url: string) => window.open(url, "_blank"); const openBlank = (url: string) => window.open(url, "_blank");

View file

@ -7,7 +7,7 @@
<AvatarsCentered v-if="notification?.account?.avatar" :src="notification?.account.avatar" <AvatarsCentered v-if="notification?.account?.avatar" :src="notification?.account.avatar"
:alt="`${notification?.account.acct}'s avatar'`" :alt="`${notification?.account.acct}'s avatar'`"
class="h-6 w-6 shrink-0 rounded ring-1 ring-white/10" /> class="h-6 w-6 shrink-0 rounded ring-1 ring-white/10" />
<span class="text-gray-200 line-clamp-1"><strong v-html="accountName"></strong> {{ text <span class="text-gray-200 line-clamp-1"><strong v-html="display_name"></strong> {{ text
}}</span> }}</span>
</Skeleton> </Skeleton>
</div> </div>
@ -63,10 +63,12 @@ const rejectFollowRequest = async () => {
isWorkingOnFollowRequest.value = false; isWorkingOnFollowRequest.value = false;
}; };
const accountName = useParsedContent( const settings = useSettings();
props.notification?.account?.display_name ?? "", const { display_name } = useParsedAccount(
props.notification?.account?.emojis ?? [], props.notification?.account,
settings,
); );
const text = computed(() => { const text = computed(() => {
if (!props.notification) return ""; if (!props.notification) return "";

View file

@ -27,7 +27,7 @@
<h2 <h2
class="text-xl font-bold text-gray-100 tracking-tight bg-gradient-to-r from-primary-300 via-purple-300 to-indigo-400 text-transparent bg-clip-text"> class="text-xl font-bold text-gray-100 tracking-tight bg-gradient-to-r from-primary-300 via-purple-300 to-indigo-400 text-transparent bg-clip-text">
<Skeleton :enabled="skeleton" :min-width="200" :max-width="350" class="h-6"> <Skeleton :enabled="skeleton" :min-width="200" :max-width="350" class="h-6">
{{ account?.display_name }} <span v-html="display_name"></span>
<iconify-icon v-if="account?.locked" icon="tabler:lock" width="1.25rem" height="1.25rem" <iconify-icon v-if="account?.locked" icon="tabler:lock" width="1.25rem" height="1.25rem"
class="text-gray-400 cursor-pointer align-text-top" class="text-gray-400 cursor-pointer align-text-top"
title="This account manually approves its followers" /> title="This account manually approves its followers" />
@ -48,7 +48,7 @@
<Skeleton :enabled="true" v-if="skeleton" class="!h-6" :min-width="50" :max-width="100" width-unit="%" <Skeleton :enabled="true" v-if="skeleton" class="!h-6" :min-width="50" :max-width="100" width-unit="%"
shape="rect" type="content"> shape="rect" type="content">
</Skeleton> </Skeleton>
<div class="prose prose-invert" v-html="parsedNote" v-else></div> <div class="prose prose-invert" v-html="note" v-else></div>
</div> </div>
<div class="mt-3 flex items-center space-x-4 px-4"> <div class="mt-3 flex items-center space-x-4 px-4">
@ -79,8 +79,8 @@
</div> </div>
</div> </div>
<div v-if="!skeleton && parsedFields.length > 0" class="mt-4 px-4 flex-col flex space-y-3"> <div v-if="!skeleton && fields && fields.length > 0" class="mt-4 px-4 flex-col flex space-y-3">
<div v-for="field of parsedFields" :key="field.name" class="flex flex-col gap-1"> <div v-for="field of fields" :key="field.name" class="flex flex-col gap-1">
<span class="text-primary-500 font-semibold" v-html="field.name"></span> <span class="text-primary-500 font-semibold" v-html="field.name"></span>
<span class="text-gray-200 prose prose-invert break-all" v-html="field.value"></span> <span class="text-gray-200 prose prose-invert break-all" v-html="field.value"></span>
</div> </div>
@ -134,13 +134,6 @@ const formattedJoin = computed(() =>
}).format(new Date(props.account?.created_at ?? 0)), }).format(new Date(props.account?.created_at ?? 0)),
); );
const parsedNote = ref("");
const parsedFields: Ref<
{
name: string;
value: string;
}[]
> = ref([]);
const handle = computed(() => { const handle = computed(() => {
if (!props.account?.acct.includes("@")) { if (!props.account?.acct.includes("@")) {
return `${props.account?.acct}@${new URL(useBaseUrl().value).host}`; return `${props.account?.acct}@${new URL(useBaseUrl().value).host}`;
@ -154,27 +147,9 @@ const visibleRoles = computed(
() => props.account?.roles.filter((r) => r.visible) ?? [], () => props.account?.roles.filter((r) => r.visible) ?? [],
); );
watch( const settings = useSettings();
skeleton, const { display_name, fields, note } = useParsedAccount(
async () => { computed(() => props.account),
if (skeleton.value) return; settings,
parsedNote.value =
useParsedContent(
props.account?.note ?? "",
props.account?.emojis ?? [],
).value ?? "";
parsedFields.value =
props.account?.fields.map((field) => ({
name:
useParsedContent(field.name, props.account?.emojis ?? [])
.value ?? "",
value:
useParsedContent(field.value, props.account?.emojis ?? [])
.value ?? "",
})) ?? [];
},
{
immediate: true,
},
); );
</script> </script>

25
composables/LinkedSSO.ts Normal file
View file

@ -0,0 +1,25 @@
import type { LysandClient } from "@lysand-org/client";
type SSOProvider = {
id: string;
name: string;
icon: string;
};
export const useLinkedSSO = (client: MaybeRef<LysandClient>) => {
if (!client) {
return ref([] as SSOProvider[]);
}
const output = ref([] as SSOProvider[]);
watchEffect(() => {
toValue(client)
?.get<SSOProvider[]>("/api/v1/sso")
.then((res) => {
output.value = res.data;
});
});
return output;
};

View file

@ -1,9 +1,11 @@
import type { LysandClient } from "@lysand-org/client"; import type { LysandClient } from "@lysand-org/client";
import { SettingIds, type Settings } from "~/settings";
import type { Status } from "~/types/mastodon/status"; import type { Status } from "~/types/mastodon/status";
export const useNoteData = ( export const useNoteData = (
noteProp: MaybeRef<Status | undefined>, noteProp: MaybeRef<Status | undefined>,
client: Ref<LysandClient>, client: Ref<LysandClient>,
settings: MaybeRef<Settings>,
) => { ) => {
const isReply = computed(() => !!toValue(noteProp)?.in_reply_to_id); const isReply = computed(() => !!toValue(noteProp)?.in_reply_to_id);
const isQuote = computed(() => !!toValue(noteProp)?.quote); const isQuote = computed(() => !!toValue(noteProp)?.quote);
@ -15,11 +17,13 @@ export const useNoteData = (
? toValue(noteProp)?.reblog ?? toValue(noteProp) ? toValue(noteProp)?.reblog ?? toValue(noteProp)
: toValue(noteProp), : toValue(noteProp),
); );
const showContentWarning = useSetting(SettingIds.ShowContentWarning);
const shouldHide = computed( const shouldHide = computed(
() => () =>
renderedNote.value?.sensitive || (renderedNote.value?.sensitive ||
!!renderedNote.value?.spoiler_text || !!renderedNote.value?.spoiler_text ||
false, false) &&
(showContentWarning.value.value as boolean),
); );
const mentions = useResolveMentions( const mentions = useResolveMentions(
computed(() => renderedNote.value?.mentions ?? []), computed(() => renderedNote.value?.mentions ?? []),
@ -29,12 +33,15 @@ export const useNoteData = (
computed(() => renderedNote.value?.content ?? ""), computed(() => renderedNote.value?.content ?? ""),
computed(() => renderedNote.value?.emojis ?? []), computed(() => renderedNote.value?.emojis ?? []),
mentions, mentions,
settings,
); );
const loaded = computed(() => content.value !== null); const loaded = computed(() => content.value !== null);
const reblogDisplayName = useParsedContent( const reblogDisplayName = useParsedContent(
toValue(noteProp)?.account.display_name ?? "", toValue(noteProp)?.account.display_name ?? "",
toValue(noteProp)?.account.emojis ?? [], toValue(noteProp)?.account.emojis ?? [],
undefined,
settings,
); );
const reblog = computed(() => const reblog = computed(() =>
isReblog.value && toValue(noteProp) && !isQuote.value isReblog.value && toValue(noteProp) && !isQuote.value

View file

@ -1,4 +1,5 @@
import { renderToString } from "vue/server-renderer"; import { renderToString } from "vue/server-renderer";
import { SettingIds, type Settings, getSettingById } from "~/settings";
import type { Account } from "~/types/mastodon/account"; import type { Account } from "~/types/mastodon/account";
import type { Emoji } from "~/types/mastodon/emoji"; import type { Emoji } from "~/types/mastodon/emoji";
import MentionComponent from "../components/social-elements/notes/mention.vue"; import MentionComponent from "../components/social-elements/notes/mention.vue";
@ -13,6 +14,7 @@ export const useParsedContent = (
content: MaybeRef<string>, content: MaybeRef<string>,
emojis: MaybeRef<Emoji[]>, emojis: MaybeRef<Emoji[]>,
mentions: MaybeRef<Account[]> = ref([]), mentions: MaybeRef<Account[]> = ref([]),
settings: MaybeRef<Settings> = ref([]),
): Ref<string | null> => { ): Ref<string | null> => {
const result = ref(null as string | null); const result = ref(null as string | null);
@ -26,28 +28,35 @@ export const useParsedContent = (
const contentHtml = document.createElement("div"); const contentHtml = document.createElement("div");
contentHtml.innerHTML = toValue(content); contentHtml.innerHTML = toValue(content);
// Replace emoji shortcodes with images const shouldRenderEmoji = getSettingById(
const paragraphs = contentHtml.querySelectorAll("p"); toValue(settings),
SettingIds.CustomEmojis,
)?.value;
for (const paragraph of paragraphs) { // Replace emoji shortcodes with images
paragraph.innerHTML = paragraph.innerHTML.replace( if (shouldRenderEmoji) {
/:([a-z0-9_-]+):/g, const paragraphs = contentHtml.querySelectorAll("p");
(match, emoji) => {
const emojiData = toValue(emojis).find( for (const paragraph of paragraphs) {
(e) => e.shortcode === emoji, paragraph.innerHTML = paragraph.innerHTML.replace(
); /:([a-z0-9_-]+):/g,
if (!emojiData) { (match, emoji) => {
return match; const emojiData = toValue(emojis).find(
} (e) => e.shortcode === emoji,
const image = document.createElement("img"); );
image.src = emojiData.url; if (!emojiData) {
image.alt = `:${emoji}:`; return match;
image.title = emojiData.shortcode; }
image.className = const image = document.createElement("img");
"h-6 align-text-bottom inline not-prose hover:scale-110 transition-transform duration-75 ease-in-out"; image.src = emojiData.url;
return image.outerHTML; image.alt = `:${emoji}:`;
}, image.title = emojiData.shortcode;
); image.className =
"h-6 align-text-bottom inline not-prose hover:scale-110 transition-transform duration-75 ease-in-out";
return image.outerHTML;
},
);
}
} }
// Replace links containing mentions with interactive mentions // Replace links containing mentions with interactive mentions
@ -76,3 +85,36 @@ export const useParsedContent = (
return result; return result;
}; };
export const useParsedAccount = (
account: MaybeRef<Account | undefined | null>,
settings: MaybeRef<Settings>,
) => {
const display_name = computed(() => toValue(account)?.display_name ?? "");
const note = computed(() => toValue(account)?.note ?? "");
const fields = computed(() => toValue(account)?.fields ?? []);
const emojis = computed(() => toValue(account)?.emojis ?? []);
const parsedDisplayName = useParsedContent(
display_name,
emojis,
undefined,
settings,
);
const parsedNote = useParsedContent(note, emojis, undefined, settings);
const parsedFields = computed(() =>
fields.value.map((field) => ({
...field,
value: useParsedContent(field.value, emojis, undefined, settings)
.value,
})),
);
return {
display_name: parsedDisplayName,
note: parsedNote,
fields: parsedFields,
};
};

56
composables/Settings.ts Normal file
View file

@ -0,0 +1,56 @@
import { StorageSerializers } from "@vueuse/core";
import {
type Setting,
type SettingIds,
type Settings,
parseFromJson,
settings,
} from "~/settings";
export const useSettings = () => {
return useLocalStorage<Settings>("lysand:settings", settings, {
serializer: {
read(raw) {
const json = StorageSerializers.object.read(raw);
return parseFromJson(json);
},
write(value) {
// key/value, with key being id and value being the value
const json = value.reduce(
(acc, setting) => {
acc[setting.id] = setting.value;
return acc;
},
{} as Record<string, unknown>,
);
return StorageSerializers.object.write(json);
},
},
});
};
export const useSetting = <T extends Setting = Setting>(id: SettingIds) => {
const settings = useSettings();
const setting: Ref<T> = ref<T>(
settings.value.find((s) => s.id === id) as T,
) as unknown as Ref<T>;
watch(settings, (newSettings) => {
setting.value = newSettings.find((s) => s.id === id) as T;
});
watch(setting, (newSetting) => {
settings.value = settings.value.map((s) =>
s.id === id ? newSetting : s,
) as Settings;
});
return setting;
};
export const getSetting = <T extends Setting = Setting>(id: SettingIds) => {
return settings.find((s) => s.id === id) as T;
};

View file

@ -45,44 +45,20 @@
import { OverlayScrollbarsComponent } from "#imports"; import { OverlayScrollbarsComponent } from "#imports";
const { width } = useWindowSize(); const { width } = useWindowSize();
const { n, o_i_d_c } = useMagicKeys(); const { n } = useMagicKeys();
const activeElement = useActiveElement();
const notUsingInput = computed(
() =>
activeElement.value?.tagName !== "INPUT" &&
activeElement.value?.tagName !== "TEXTAREA",
);
const identity = useCurrentIdentity(); const identity = useCurrentIdentity();
const client = useClient();
const providers = useSSOConfig();
watchEffect(async () => { watchEffect(async () => {
if (n?.value) { if (n?.value && notUsingInput.value) {
// Wait 50ms // Wait 50ms
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
useEvent("composer:open"); useEvent("composer:open");
} }
if (o_i_d_c?.value) {
useEvent("notification:new", {
type: "progress",
title: "Linking your account",
persistent: true,
});
const issuer = providers.value?.providers[0];
if (!issuer) {
console.error("No SSO provider found");
return;
}
const response = await fetch(new URL("/api/v1/sso", client.value.url), {
method: "POST",
headers: {
Authorization: `Bearer ${identity.value?.tokens.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
issuer: issuer.id,
}),
});
const json = await response.json();
window.location.href = json.link;
}
}); });
</script> </script>

35
pages/settings/index.vue Normal file
View file

@ -0,0 +1,35 @@
<template>
<SidebarsSettings>
<template #behaviour>
<SettingsRenderer :setting="setting" v-for="setting of getSettingsForPath(
settings,
SettingPages.Behaviour,
)" :key="setting.id" />
</template>
<template #appearance>
<SettingsRenderer :setting="setting" v-for="setting of getSettingsForPath(
settings,
SettingPages.Appearance,
)" :key="setting.id" />
</template>
<template #advanced>
<SettingsRenderer :setting="setting" v-for="setting of getSettingsForPath(
settings,
SettingPages.Advanced,
)" :key="setting.id" />
</template>
<template #account>
<SettingsProfileEditor />
</template>
</SidebarsSettings>
</template>
<script setup lang="ts">
import { SettingPages, getSettingsForPath } from "~/settings";
definePageMeta({
layout: "app",
});
const settings = useSettings();
</script>

View file

@ -11,6 +11,7 @@ export type Setting<T = SettingType> = {
id: string; id: string;
title: string; title: string;
description: string; description: string;
notImplemented?: boolean;
type: T; type: T;
value: T extends SettingType.String | SettingType.Code value: T extends SettingType.String | SettingType.Code
? string ? string
@ -29,30 +30,59 @@ export type Setting<T = SettingType> = {
path: SettingPages; path: SettingPages;
}; };
export type Settings = Setting[];
export enum SettingPages { export enum SettingPages {
Account = "account",
Behaviour = "behaviour", Behaviour = "behaviour",
Appearance = "appearance",
Advanced = "advanced", Advanced = "advanced",
Appearance = "appearance",
} }
export const getSettingsForPath = (settings: Settings, path: string) => export const getSettingsForPath = (
settings.filter((setting) => setting.path === path); settingsToFilterIn: Settings,
path: SettingPages,
) => settingsToFilterIn.filter((setting) => setting.path === path);
export const getSettingById = (settings: Settings, id: string) => export const getSettingById = (settingsToFilterIn: Settings, id: SettingIds) =>
settings.find((setting) => setting.id === id); settingsToFilterIn.find((setting) => setting.id === id);
export const settings: Settings = [ export const parseFromJson = (json: Record<string, unknown>) => {
const finalSettings = structuredClone(settings);
// Override the default values with the values from the JSON except for the user value
for (const setting of finalSettings) {
if (setting.id in json) {
setting.value = json[setting.id] as (typeof setting)["value"];
}
}
return finalSettings;
};
export enum SettingIds {
MFM = "mfm",
CustomCSS = "custom-css",
Theme = "theme",
CustomEmojis = "custom-emojis",
ShowContentWarning = "show-content-warning",
PopupAvatarHover = "popup-avatar-hover",
ConfirmDelete = "confirm-delete",
ConfirmFollow = "confirm-follow",
ConfirmReblog = "confirm-reblog",
ConfirmFavourite = "confirm-favourite",
}
export const settings = [
{ {
id: "mfm", id: SettingIds.MFM,
title: "Render MFM", title: "Render MFM",
description: "Render Misskey-Flavoured Markdown", description: "Render Misskey-Flavoured Markdown",
type: SettingType.Boolean, type: SettingType.Boolean,
value: false, value: false,
path: SettingPages.Behaviour, path: SettingPages.Behaviour,
notImplemented: true,
} as Setting<SettingType.Boolean>, } as Setting<SettingType.Boolean>,
{ {
id: "custom-css", id: SettingIds.CustomCSS,
title: "Custom CSS", title: "Custom CSS",
description: "Custom CSS for the UI", description: "Custom CSS for the UI",
type: SettingType.Code, type: SettingType.Code,
@ -61,7 +91,7 @@ export const settings: Settings = [
path: SettingPages.Appearance, path: SettingPages.Appearance,
} as Setting<SettingType.Code>, } as Setting<SettingType.Code>,
{ {
id: "theme", id: SettingIds.Theme,
title: "Theme", title: "Theme",
description: "UI theme", description: "UI theme",
type: SettingType.Enum, type: SettingType.Enum,
@ -70,7 +100,7 @@ export const settings: Settings = [
path: SettingPages.Appearance, path: SettingPages.Appearance,
} as Setting<SettingType.Enum>, } as Setting<SettingType.Enum>,
{ {
id: "custom-emojis", id: SettingIds.CustomEmojis,
title: "Render Custom Emojis", title: "Render Custom Emojis",
description: "Render custom emojis", description: "Render custom emojis",
type: SettingType.Boolean, type: SettingType.Boolean,
@ -78,7 +108,7 @@ export const settings: Settings = [
path: SettingPages.Behaviour, path: SettingPages.Behaviour,
} as Setting<SettingType.Boolean>, } as Setting<SettingType.Boolean>,
{ {
id: "show-content-warning", id: SettingIds.ShowContentWarning,
title: "Show Content Warning", title: "Show Content Warning",
description: "Show content warnings on notes marked sensitive/spoiler", description: "Show content warnings on notes marked sensitive/spoiler",
type: SettingType.Boolean, type: SettingType.Boolean,
@ -86,43 +116,50 @@ export const settings: Settings = [
path: SettingPages.Behaviour, path: SettingPages.Behaviour,
} as Setting<SettingType.Boolean>, } as Setting<SettingType.Boolean>,
{ {
id: "popup-avatar-hover", id: SettingIds.PopupAvatarHover,
title: "Popup Profile Hover", title: "Popup Profile Hover",
description: "Show profile popup when hovering over a user's avatar", description: "Show profile popup when hovering over a user's avatar",
type: SettingType.Boolean, type: SettingType.Boolean,
value: true, value: true,
path: SettingPages.Behaviour, path: SettingPages.Behaviour,
notImplemented: true,
} as Setting<SettingType.Boolean>, } as Setting<SettingType.Boolean>,
{ {
id: "confirm-delete", id: SettingIds.ConfirmDelete,
title: "Confirm Delete", title: "Confirm Delete",
description: "Confirm before deleting a note", description: "Confirm before deleting a note",
type: SettingType.Boolean, type: SettingType.Boolean,
value: false, value: false,
path: SettingPages.Behaviour, path: SettingPages.Behaviour,
notImplemented: true,
} as Setting<SettingType.Boolean>, } as Setting<SettingType.Boolean>,
{ {
id: "confirm-follow", id: SettingIds.ConfirmFollow,
title: "Confirm Follow", title: "Confirm Follow",
description: "Confirm before following/unfollowing a user", description: "Confirm before following/unfollowing a user",
type: SettingType.Boolean, type: SettingType.Boolean,
value: false, value: false,
path: SettingPages.Behaviour, path: SettingPages.Behaviour,
notImplemented: true,
} as Setting<SettingType.Boolean>, } as Setting<SettingType.Boolean>,
{ {
id: "confirm-reblog", id: SettingIds.ConfirmReblog,
title: "Confirm Reblog", title: "Confirm Reblog",
description: "Confirm before reblogging a note", description: "Confirm before reblogging a note",
type: SettingType.Boolean, type: SettingType.Boolean,
value: false, value: false,
path: SettingPages.Behaviour, path: SettingPages.Behaviour,
notImplemented: true,
} as Setting<SettingType.Boolean>, } as Setting<SettingType.Boolean>,
{ {
id: "confirm-favourite", id: SettingIds.ConfirmFavourite,
title: "Confirm Favourite", title: "Confirm Favourite",
description: "Confirm before favouriting a note", description: "Confirm before favouriting a note",
type: SettingType.Boolean, type: SettingType.Boolean,
value: false, value: false,
path: SettingPages.Behaviour, path: SettingPages.Behaviour,
notImplemented: true,
} as Setting<SettingType.Boolean>, } as Setting<SettingType.Boolean>,
]; ];
export type Settings = typeof settings;