mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Add note editing capabilities
This commit is contained in:
parent
5a8e4e5d0f
commit
29d98c9f2c
|
|
@ -47,7 +47,9 @@
|
|||
</ComposerButton>
|
||||
<ButtonsPrimary :loading="loading" @click="send" class="ml-auto rounded-full"
|
||||
:disabled="!canSubmit || loading">
|
||||
<span>Send!</span>
|
||||
<span>{{
|
||||
respondingType === "edit" ? "Edit!" : "Send!"
|
||||
}}</span>
|
||||
</ButtonsPrimary>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -68,7 +70,7 @@ const { input: content } = useTextareaAutosize({
|
|||
});
|
||||
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
|
||||
const respondingTo = ref<Status | null>(null);
|
||||
const respondingType = ref<"reply" | "quote" | null>(null);
|
||||
const respondingType = ref<"reply" | "quote" | "edit" | null>(null);
|
||||
const me = useMe();
|
||||
const cw = ref(false);
|
||||
const cwContent = ref("");
|
||||
|
|
@ -161,6 +163,30 @@ onMounted(() => {
|
|||
content.value = `@${note.account.acct} `;
|
||||
textarea.value?.focus();
|
||||
});
|
||||
|
||||
useListen("composer:edit", async (note: Status) => {
|
||||
loading.value = true;
|
||||
files.value = note.media_attachments.map((file) => ({
|
||||
id: nanoid(),
|
||||
file: new File([], file.url),
|
||||
progress: 1,
|
||||
api_id: file.id,
|
||||
alt_text: file.description ?? undefined,
|
||||
}));
|
||||
|
||||
// Fetch source
|
||||
const source = await client.value?.getStatusSource(note.id);
|
||||
|
||||
if (source?.data) {
|
||||
respondingTo.value = note;
|
||||
respondingType.value = "edit";
|
||||
content.value = source.data.text;
|
||||
cwContent.value = source.data.spoiler_text;
|
||||
textarea.value?.focus();
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
|
|
@ -185,6 +211,46 @@ const client = useMegalodon(tokenData);
|
|||
const send = async () => {
|
||||
loading.value = true;
|
||||
|
||||
if (respondingType.value === "edit") {
|
||||
fetch(
|
||||
new URL(
|
||||
`/api/v1/statuses/${respondingTo.value?.id}`,
|
||||
client.value?.baseUrl ?? "",
|
||||
).toString(),
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokenData.value?.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: content.value?.trim() ?? "",
|
||||
content_type: markdown.value
|
||||
? "text/markdown"
|
||||
: "text/plain",
|
||||
spoiler_text: cw.value ? cwContent.value.trim() : undefined,
|
||||
sensitive: cw.value,
|
||||
media_ids: files.value
|
||||
.filter((file) => !!file.api_id)
|
||||
.map((file) => file.api_id),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to edit status");
|
||||
}
|
||||
|
||||
content.value = "";
|
||||
loading.value = false;
|
||||
useEvent("composer:send-edit", await res.json());
|
||||
})
|
||||
.finally(() => {
|
||||
useEvent("composer:close");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(new URL("/api/v1/statuses", client.value?.baseUrl ?? "").toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
<Teleport to="body">
|
||||
<Dialog.Positioner
|
||||
class="flex min-h-full items-start z-50 justify-center p-4 text-center sm:items-center sm:p-0 fixed inset-0 w-screen overflow-y-auto">
|
||||
class="flex items-start z-50 justify-center p-4 text-center sm:items-center sm:p-0 fixed inset-0 w-screen h-screen overflow-y-hidden">
|
||||
<HeadlessTransitionChild as="template" enter="ease-out duration-200" enter-from="opacity-0"
|
||||
enter-to="opacity-100" leave="ease-in duration-200" leave-from="opacity-100"
|
||||
leave-to="opacity-0">
|
||||
|
|
@ -16,9 +16,11 @@
|
|||
enter-to="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200"
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<Dialog.Content
|
||||
class="relative transform overflow-hidden rounded-lg bg-dark-700 ring-1 ring-dark-800 text-left shadow-xl transition-all sm:my-8 w-full max-w-xl">
|
||||
<Composer v-if="instance" :instance="instance" />
|
||||
<Dialog.Content class="overflow-y-auto w-full max-h-full md:py-16">
|
||||
<div
|
||||
class="relative overflow-hidden max-w-xl mx-auto rounded-lg bg-dark-700 ring-1 ring-dark-800 text-left shadow-xl transition-all w-full">
|
||||
<Composer v-if="instance" :instance="instance" />
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</HeadlessTransitionChild>
|
||||
</Dialog.Positioner>
|
||||
|
|
@ -40,6 +42,11 @@ useListen("note:quote", async (note) => {
|
|||
await nextTick();
|
||||
useEvent("composer:quote", note);
|
||||
});
|
||||
useListen("note:edit", async (note) => {
|
||||
open.value = true;
|
||||
await nextTick();
|
||||
useEvent("composer:edit", note);
|
||||
});
|
||||
useListen("composer:open", () => {
|
||||
if (tokenData.value) open.value = true;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
</video>
|
||||
<a v-else class="bg-dark-800 w-full h-full rounded flex items-center justify-center" :href="attachment.url"
|
||||
target="_blank" download>
|
||||
<div class="flex flex-col items-center gap-2 max-w-56 overflow-hidden text-ellipsis">
|
||||
<div class="flex flex-col items-center gap-2 text-center max-w-56 overflow-hidden text-ellipsis">
|
||||
<iconify-icon icon="tabler:file" width="none" class="size-10 text-gray-300" />
|
||||
<p class="text-gray-300 text-sm font-mono">{{ getFilename(attachment.url) }}</p>
|
||||
<p class="text-gray-300 text-xs" v-if="attachment.meta?.length">{{
|
||||
|
|
@ -60,7 +60,9 @@ const getFilename = (url: string) => {
|
|||
if (url.includes("/media/proxy")) {
|
||||
// Decode last part of URL as base64url, which is the real URL
|
||||
const realUrl = atob(url.split("/").pop() ?? "");
|
||||
return realUrl.substring(realUrl.lastIndexOf("/") + 1);
|
||||
return decodeURIComponent(
|
||||
realUrl.substring(realUrl.lastIndexOf("/") + 1),
|
||||
);
|
||||
}
|
||||
const path = new URL(url).pathname;
|
||||
return path.substring(path.lastIndexOf("/") + 1);
|
||||
|
|
|
|||
|
|
@ -12,33 +12,35 @@
|
|||
<span><strong v-html="reblogDisplayName"></strong> reblogged</span>
|
||||
</Skeleton>
|
||||
</div>
|
||||
<SocialElementsNotesReplyHeader v-if="isReply" :account_id="note?.in_reply_to_account_id ?? null" />
|
||||
<SocialElementsNotesHeader :note="note" :small="small" />
|
||||
<LazySocialElementsNotesNoteContent :note="note" :loaded="loaded" :url="url" :content="content"
|
||||
<SocialElementsNotesReplyHeader v-if="isReply" :account_id="outputtedNote?.in_reply_to_account_id ?? null" />
|
||||
<SocialElementsNotesHeader :note="outputtedNote" :small="small" />
|
||||
<LazySocialElementsNotesNoteContent :note="outputtedNote" :loaded="loaded" :url="url" :content="content"
|
||||
:is-quote="isQuote" :should-hide="shouldHide" />
|
||||
<Skeleton class="!h-10 w-full mt-6" :enabled="!props.note || !loaded" v-if="!small || !showInteractions">
|
||||
<div v-if="showInteractions"
|
||||
class="mt-6 flex flex-row items-stretch disabled:*:opacity-70 [&>button]:max-w-28 disabled:*:cursor-not-allowed relative justify-around text-sm h-10 hover:enabled:[&>button]:bg-dark-800 [&>button]:duration-200 [&>button]:rounded [&>button]:flex [&>button]:flex-1 [&>button]:flex-row [&>button]:items-center [&>button]:justify-center">
|
||||
<button class="group" @click="note && useEvent('note:reply', note)" :disabled="!isSignedIn">
|
||||
<button class="group" @click="outputtedNote && useEvent('note:reply', outputtedNote)"
|
||||
:disabled="!isSignedIn">
|
||||
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:arrow-back-up"
|
||||
class="text-gray-200 group-hover:group-enabled:text-blue-600" aria-hidden="true" />
|
||||
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(note?.replies_count) }}</span>
|
||||
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.replies_count) }}</span>
|
||||
</button>
|
||||
<button class="group" @click="likeFn" :disabled="!isSignedIn">
|
||||
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:heart" v-if="!note?.favourited"
|
||||
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:heart" v-if="!outputtedNote?.favourited"
|
||||
class="size-5 text-gray-200 group-hover:group-enabled:text-pink-600" aria-hidden="true" />
|
||||
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:heart-filled" v-else
|
||||
class="size-5 text-pink-600 group-hover:group-enabled:text-gray-200" aria-hidden="true" />
|
||||
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(note?.favourites_count) }}</span>
|
||||
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.favourites_count) }}</span>
|
||||
</button>
|
||||
<button class="group" @click="reblogFn" :disabled="!isSignedIn">
|
||||
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:repeat" v-if="!note?.reblogged"
|
||||
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:repeat" v-if="!outputtedNote?.reblogged"
|
||||
class="size-5 text-gray-200 group-hover:group-enabled:text-green-600" aria-hidden="true" />
|
||||
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:repeat" v-else
|
||||
class="size-5 text-green-600 group-hover:group-enabled:text-gray-200" aria-hidden="true" />
|
||||
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(note?.reblogs_count) }}</span>
|
||||
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.reblogs_count) }}</span>
|
||||
</button>
|
||||
<button class="group" @click="note && useEvent('note:quote', note)" :disabled="!isSignedIn">
|
||||
<button class="group" @click="outputtedNote && useEvent('note:quote', outputtedNote)"
|
||||
:disabled="!isSignedIn">
|
||||
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:quote"
|
||||
class="size-5 text-gray-200 group-hover:group-enabled:text-blue-600" aria-hidden="true" />
|
||||
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(0) }}</span>
|
||||
|
|
@ -51,9 +53,15 @@
|
|||
</template>
|
||||
|
||||
<template #items>
|
||||
<Menu.Item value="" v-if="isSignedIn && outputtedNote?.account.id === me?.id">
|
||||
<ButtonsDropdownElement @click="outputtedNote && useEvent('note:edit', outputtedNote)"
|
||||
icon="tabler:pencil" class="w-full">
|
||||
Edit
|
||||
</ButtonsDropdownElement>
|
||||
</Menu.Item>
|
||||
<Menu.Item value="">
|
||||
<ButtonsDropdownElement @click="copy(JSON.stringify(note, null, 4))" icon="tabler:code"
|
||||
class="w-full">
|
||||
<ButtonsDropdownElement @click="copy(JSON.stringify(outputtedNote, null, 4))"
|
||||
icon="tabler:code" class="w-full">
|
||||
Copy API
|
||||
Response
|
||||
</ButtonsDropdownElement>
|
||||
|
|
@ -95,12 +103,19 @@ const props = withDefaults(
|
|||
|
||||
const noteRef = ref(props.note);
|
||||
|
||||
useListen("composer:send-edit", (note) => {
|
||||
if (note.id === noteRef.value?.id) {
|
||||
noteRef.value = note;
|
||||
}
|
||||
});
|
||||
|
||||
const tokenData = useTokenData();
|
||||
const isSignedIn = useSignedIn();
|
||||
const me = useMe();
|
||||
const client = useMegalodon(tokenData);
|
||||
const {
|
||||
loaded,
|
||||
note,
|
||||
note: outputtedNote,
|
||||
remove,
|
||||
content,
|
||||
shouldHide,
|
||||
|
|
@ -120,15 +135,19 @@ const numberFormat = (number = 0) =>
|
|||
}).format(number);
|
||||
|
||||
const likeFn = async () => {
|
||||
if (!note.value) return;
|
||||
if (note.value.favourited) {
|
||||
const output = await client.value?.unfavouriteStatus(note.value.id);
|
||||
if (!outputtedNote.value) return;
|
||||
if (outputtedNote.value.favourited) {
|
||||
const output = await client.value?.unfavouriteStatus(
|
||||
outputtedNote.value.id,
|
||||
);
|
||||
|
||||
if (output?.data) {
|
||||
noteRef.value = output.data;
|
||||
}
|
||||
} else {
|
||||
const output = await client.value?.favouriteStatus(note.value.id);
|
||||
const output = await client.value?.favouriteStatus(
|
||||
outputtedNote.value.id,
|
||||
);
|
||||
|
||||
if (output?.data) {
|
||||
noteRef.value = output.data;
|
||||
|
|
@ -137,15 +156,17 @@ const likeFn = async () => {
|
|||
};
|
||||
|
||||
const reblogFn = async () => {
|
||||
if (!note.value) return;
|
||||
if (note.value?.reblogged) {
|
||||
const output = await client.value?.unreblogStatus(note.value.id);
|
||||
if (!outputtedNote.value) return;
|
||||
if (outputtedNote.value?.reblogged) {
|
||||
const output = await client.value?.unreblogStatus(
|
||||
outputtedNote.value.id,
|
||||
);
|
||||
|
||||
if (output?.data) {
|
||||
noteRef.value = output.data;
|
||||
}
|
||||
} else {
|
||||
const output = await client.value?.reblogStatus(note.value.id);
|
||||
const output = await client.value?.reblogStatus(outputtedNote.value.id);
|
||||
|
||||
if (output?.data.reblog) {
|
||||
noteRef.value = output.data.reblog;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ type ApplicationEvents = {
|
|||
"composer:open": undefined;
|
||||
"composer:reply": Status;
|
||||
"composer:quote": Status;
|
||||
"composer:edit": Status;
|
||||
"composer:send": Status;
|
||||
"composer:send-edit": Status;
|
||||
"composer:close": undefined;
|
||||
"notification:new": NotificationEvent;
|
||||
"attachment:view": Attachment;
|
||||
|
|
|
|||
|
|
@ -22,12 +22,12 @@ export const useNoteData = (
|
|||
false,
|
||||
);
|
||||
const mentions = useResolveMentions(
|
||||
renderedNote.value?.mentions ?? [],
|
||||
computed(() => renderedNote.value?.mentions ?? []),
|
||||
client.value,
|
||||
);
|
||||
const content = useParsedContent(
|
||||
renderedNote.value?.content ?? "",
|
||||
renderedNote.value?.emojis ?? [],
|
||||
computed(() => renderedNote.value?.content ?? ""),
|
||||
computed(() => renderedNote.value?.emojis ?? []),
|
||||
mentions,
|
||||
);
|
||||
const loaded = computed(() => content.value !== null);
|
||||
|
|
|
|||
|
|
@ -10,17 +10,21 @@ import MentionComponent from "../components/social-elements/notes/mention.vue";
|
|||
* @returns Reactive object with the parsed content
|
||||
*/
|
||||
export const useParsedContent = (
|
||||
content: string,
|
||||
emojis: Emoji[],
|
||||
content: MaybeRef<string>,
|
||||
emojis: MaybeRef<Emoji[]>,
|
||||
mentions: MaybeRef<Account[]> = ref([]),
|
||||
): Ref<string | null> => {
|
||||
const result = ref(null as string | null);
|
||||
|
||||
watch(
|
||||
mentions,
|
||||
isRef(content)
|
||||
? isRef(emojis)
|
||||
? [content, mentions, emojis]
|
||||
: [content, mentions]
|
||||
: mentions,
|
||||
async () => {
|
||||
const contentHtml = document.createElement("div");
|
||||
contentHtml.innerHTML = content;
|
||||
contentHtml.innerHTML = toValue(content);
|
||||
|
||||
// Replace emoji shortcodes with images
|
||||
const paragraphs = contentHtml.querySelectorAll("p");
|
||||
|
|
@ -29,7 +33,7 @@ export const useParsedContent = (
|
|||
paragraph.innerHTML = paragraph.innerHTML.replace(
|
||||
/:([a-z0-9_-]+):/g,
|
||||
(match, emoji) => {
|
||||
const emojiData = emojis.find(
|
||||
const emojiData = toValue(emojis).find(
|
||||
(e) => e.shortcode === emoji,
|
||||
);
|
||||
if (!emojiData) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Account } from "~/types/mastodon/account";
|
|||
import type { Mention } from "~/types/mastodon/mention";
|
||||
|
||||
export const useResolveMentions = (
|
||||
mentions: Mention[],
|
||||
mentions: Ref<Mention[]>,
|
||||
client: Mastodon | null,
|
||||
): Ref<Account[]> => {
|
||||
if (!client) {
|
||||
|
|
@ -12,14 +12,14 @@ export const useResolveMentions = (
|
|||
|
||||
const output = ref<Account[]>([]);
|
||||
|
||||
(async () => {
|
||||
watch(mentions, async () => {
|
||||
output.value = await Promise.all(
|
||||
mentions.map(async (mention) => {
|
||||
toValue(mentions).map(async (mention) => {
|
||||
const response = await client.getAccount(mention.id);
|
||||
return response.data;
|
||||
}),
|
||||
);
|
||||
})();
|
||||
});
|
||||
|
||||
return output;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue