feat: Add note editing capabilities

This commit is contained in:
Jesse Wierzbinski 2024-06-05 20:42:44 -10:00
parent 5a8e4e5d0f
commit 29d98c9f2c
No known key found for this signature in database
8 changed files with 143 additions and 41 deletions

View file

@ -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: {

View file

@ -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;
});

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

@ -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) {

View file

@ -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;
};