Skip to content

Commit 29d98c9

Browse files
committed
feat: ✨ Add note editing capabilities
1 parent 5a8e4e5 commit 29d98c9

File tree

8 files changed

+143
-41
lines changed

8 files changed

+143
-41
lines changed

components/composer/composer.vue

+68-2
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@
4747
</ComposerButton>
4848
<ButtonsPrimary :loading="loading" @click="send" class="ml-auto rounded-full"
4949
:disabled="!canSubmit || loading">
50-
<span>Send!</span>
50+
<span>{{
51+
respondingType === "edit" ? "Edit!" : "Send!"
52+
}}</span>
5153
</ButtonsPrimary>
5254
</div>
5355
</div>
@@ -68,7 +70,7 @@ const { input: content } = useTextareaAutosize({
6870
});
6971
const { Control_Enter, Command_Enter, Control_Alt } = useMagicKeys();
7072
const respondingTo = ref<Status | null>(null);
71-
const respondingType = ref<"reply" | "quote" | null>(null);
73+
const respondingType = ref<"reply" | "quote" | "edit" | null>(null);
7274
const me = useMe();
7375
const cw = ref(false);
7476
const cwContent = ref("");
@@ -161,6 +163,30 @@ onMounted(() => {
161163
content.value = `@${note.account.acct} `;
162164
textarea.value?.focus();
163165
});
166+
167+
useListen("composer:edit", async (note: Status) => {
168+
loading.value = true;
169+
files.value = note.media_attachments.map((file) => ({
170+
id: nanoid(),
171+
file: new File([], file.url),
172+
progress: 1,
173+
api_id: file.id,
174+
alt_text: file.description ?? undefined,
175+
}));
176+
177+
// Fetch source
178+
const source = await client.value?.getStatusSource(note.id);
179+
180+
if (source?.data) {
181+
respondingTo.value = note;
182+
respondingType.value = "edit";
183+
content.value = source.data.text;
184+
cwContent.value = source.data.spoiler_text;
185+
textarea.value?.focus();
186+
}
187+
188+
loading.value = false;
189+
});
164190
});
165191
166192
watchEffect(() => {
@@ -185,6 +211,46 @@ const client = useMegalodon(tokenData);
185211
const send = async () => {
186212
loading.value = true;
187213
214+
if (respondingType.value === "edit") {
215+
fetch(
216+
new URL(
217+
`/api/v1/statuses/${respondingTo.value?.id}`,
218+
client.value?.baseUrl ?? "",
219+
).toString(),
220+
{
221+
method: "PUT",
222+
headers: {
223+
"Content-Type": "application/json",
224+
Authorization: `Bearer ${tokenData.value?.access_token}`,
225+
},
226+
body: JSON.stringify({
227+
status: content.value?.trim() ?? "",
228+
content_type: markdown.value
229+
? "text/markdown"
230+
: "text/plain",
231+
spoiler_text: cw.value ? cwContent.value.trim() : undefined,
232+
sensitive: cw.value,
233+
media_ids: files.value
234+
.filter((file) => !!file.api_id)
235+
.map((file) => file.api_id),
236+
}),
237+
},
238+
)
239+
.then(async (res) => {
240+
if (!res.ok) {
241+
throw new Error("Failed to edit status");
242+
}
243+
244+
content.value = "";
245+
loading.value = false;
246+
useEvent("composer:send-edit", await res.json());
247+
})
248+
.finally(() => {
249+
useEvent("composer:close");
250+
});
251+
return;
252+
}
253+
188254
fetch(new URL("/api/v1/statuses", client.value?.baseUrl ?? "").toString(), {
189255
method: "POST",
190256
headers: {

components/composer/modal.client.vue

+11-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
<Teleport to="body">
77
<Dialog.Positioner
8-
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">
8+
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">
99
<HeadlessTransitionChild as="template" enter="ease-out duration-200" enter-from="opacity-0"
1010
enter-to="opacity-100" leave="ease-in duration-200" leave-from="opacity-100"
1111
leave-to="opacity-0">
@@ -16,9 +16,11 @@
1616
enter-to="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200"
1717
leave-from="opacity-100 translate-y-0 sm:scale-100"
1818
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
19-
<Dialog.Content
20-
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">
21-
<Composer v-if="instance" :instance="instance" />
19+
<Dialog.Content class="overflow-y-auto w-full max-h-full md:py-16">
20+
<div
21+
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">
22+
<Composer v-if="instance" :instance="instance" />
23+
</div>
2224
</Dialog.Content>
2325
</HeadlessTransitionChild>
2426
</Dialog.Positioner>
@@ -40,6 +42,11 @@ useListen("note:quote", async (note) => {
4042
await nextTick();
4143
useEvent("composer:quote", note);
4244
});
45+
useListen("note:edit", async (note) => {
46+
open.value = true;
47+
await nextTick();
48+
useEvent("composer:edit", note);
49+
});
4350
useListen("composer:open", () => {
4451
if (tokenData.value) open.value = true;
4552
});

components/social-elements/notes/attachment.vue

+4-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
</video>
1111
<a v-else class="bg-dark-800 w-full h-full rounded flex items-center justify-center" :href="attachment.url"
1212
target="_blank" download>
13-
<div class="flex flex-col items-center gap-2 max-w-56 overflow-hidden text-ellipsis">
13+
<div class="flex flex-col items-center gap-2 text-center max-w-56 overflow-hidden text-ellipsis">
1414
<iconify-icon icon="tabler:file" width="none" class="size-10 text-gray-300" />
1515
<p class="text-gray-300 text-sm font-mono">{{ getFilename(attachment.url) }}</p>
1616
<p class="text-gray-300 text-xs" v-if="attachment.meta?.length">{{
@@ -60,7 +60,9 @@ const getFilename = (url: string) => {
6060
if (url.includes("/media/proxy")) {
6161
// Decode last part of URL as base64url, which is the real URL
6262
const realUrl = atob(url.split("/").pop() ?? "");
63-
return realUrl.substring(realUrl.lastIndexOf("/") + 1);
63+
return decodeURIComponent(
64+
realUrl.substring(realUrl.lastIndexOf("/") + 1),
65+
);
6466
}
6567
const path = new URL(url).pathname;
6668
return path.substring(path.lastIndexOf("/") + 1);

components/social-elements/notes/note.vue

+42-21
Original file line numberDiff line numberDiff line change
@@ -12,33 +12,35 @@
1212
<span><strong v-html="reblogDisplayName"></strong> reblogged</span>
1313
</Skeleton>
1414
</div>
15-
<SocialElementsNotesReplyHeader v-if="isReply" :account_id="note?.in_reply_to_account_id ?? null" />
16-
<SocialElementsNotesHeader :note="note" :small="small" />
17-
<LazySocialElementsNotesNoteContent :note="note" :loaded="loaded" :url="url" :content="content"
15+
<SocialElementsNotesReplyHeader v-if="isReply" :account_id="outputtedNote?.in_reply_to_account_id ?? null" />
16+
<SocialElementsNotesHeader :note="outputtedNote" :small="small" />
17+
<LazySocialElementsNotesNoteContent :note="outputtedNote" :loaded="loaded" :url="url" :content="content"
1818
:is-quote="isQuote" :should-hide="shouldHide" />
1919
<Skeleton class="!h-10 w-full mt-6" :enabled="!props.note || !loaded" v-if="!small || !showInteractions">
2020
<div v-if="showInteractions"
2121
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">
22-
<button class="group" @click="note && useEvent('note:reply', note)" :disabled="!isSignedIn">
22+
<button class="group" @click="outputtedNote && useEvent('note:reply', outputtedNote)"
23+
:disabled="!isSignedIn">
2324
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:arrow-back-up"
2425
class="text-gray-200 group-hover:group-enabled:text-blue-600" aria-hidden="true" />
25-
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(note?.replies_count) }}</span>
26+
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.replies_count) }}</span>
2627
</button>
2728
<button class="group" @click="likeFn" :disabled="!isSignedIn">
28-
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:heart" v-if="!note?.favourited"
29+
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:heart" v-if="!outputtedNote?.favourited"
2930
class="size-5 text-gray-200 group-hover:group-enabled:text-pink-600" aria-hidden="true" />
3031
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:heart-filled" v-else
3132
class="size-5 text-pink-600 group-hover:group-enabled:text-gray-200" aria-hidden="true" />
32-
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(note?.favourites_count) }}</span>
33+
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.favourites_count) }}</span>
3334
</button>
3435
<button class="group" @click="reblogFn" :disabled="!isSignedIn">
35-
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:repeat" v-if="!note?.reblogged"
36+
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:repeat" v-if="!outputtedNote?.reblogged"
3637
class="size-5 text-gray-200 group-hover:group-enabled:text-green-600" aria-hidden="true" />
3738
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:repeat" v-else
3839
class="size-5 text-green-600 group-hover:group-enabled:text-gray-200" aria-hidden="true" />
39-
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(note?.reblogs_count) }}</span>
40+
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(outputtedNote?.reblogs_count) }}</span>
4041
</button>
41-
<button class="group" @click="note && useEvent('note:quote', note)" :disabled="!isSignedIn">
42+
<button class="group" @click="outputtedNote && useEvent('note:quote', outputtedNote)"
43+
:disabled="!isSignedIn">
4244
<iconify-icon width="1.25rem" height="1.25rem" icon="tabler:quote"
4345
class="size-5 text-gray-200 group-hover:group-enabled:text-blue-600" aria-hidden="true" />
4446
<span class="text-gray-400 mt-0.5 ml-2">{{ numberFormat(0) }}</span>
@@ -51,9 +53,15 @@
5153
</template>
5254

5355
<template #items>
56+
<Menu.Item value="" v-if="isSignedIn && outputtedNote?.account.id === me?.id">
57+
<ButtonsDropdownElement @click="outputtedNote && useEvent('note:edit', outputtedNote)"
58+
icon="tabler:pencil" class="w-full">
59+
Edit
60+
</ButtonsDropdownElement>
61+
</Menu.Item>
5462
<Menu.Item value="">
55-
<ButtonsDropdownElement @click="copy(JSON.stringify(note, null, 4))" icon="tabler:code"
56-
class="w-full">
63+
<ButtonsDropdownElement @click="copy(JSON.stringify(outputtedNote, null, 4))"
64+
icon="tabler:code" class="w-full">
5765
Copy API
5866
Response
5967
</ButtonsDropdownElement>
@@ -95,12 +103,19 @@ const props = withDefaults(
95103
96104
const noteRef = ref(props.note);
97105
106+
useListen("composer:send-edit", (note) => {
107+
if (note.id === noteRef.value?.id) {
108+
noteRef.value = note;
109+
}
110+
});
111+
98112
const tokenData = useTokenData();
99113
const isSignedIn = useSignedIn();
114+
const me = useMe();
100115
const client = useMegalodon(tokenData);
101116
const {
102117
loaded,
103-
note,
118+
note: outputtedNote,
104119
remove,
105120
content,
106121
shouldHide,
@@ -120,15 +135,19 @@ const numberFormat = (number = 0) =>
120135
}).format(number);
121136
122137
const likeFn = async () => {
123-
if (!note.value) return;
124-
if (note.value.favourited) {
125-
const output = await client.value?.unfavouriteStatus(note.value.id);
138+
if (!outputtedNote.value) return;
139+
if (outputtedNote.value.favourited) {
140+
const output = await client.value?.unfavouriteStatus(
141+
outputtedNote.value.id,
142+
);
126143
127144
if (output?.data) {
128145
noteRef.value = output.data;
129146
}
130147
} else {
131-
const output = await client.value?.favouriteStatus(note.value.id);
148+
const output = await client.value?.favouriteStatus(
149+
outputtedNote.value.id,
150+
);
132151
133152
if (output?.data) {
134153
noteRef.value = output.data;
@@ -137,15 +156,17 @@ const likeFn = async () => {
137156
};
138157
139158
const reblogFn = async () => {
140-
if (!note.value) return;
141-
if (note.value?.reblogged) {
142-
const output = await client.value?.unreblogStatus(note.value.id);
159+
if (!outputtedNote.value) return;
160+
if (outputtedNote.value?.reblogged) {
161+
const output = await client.value?.unreblogStatus(
162+
outputtedNote.value.id,
163+
);
143164
144165
if (output?.data) {
145166
noteRef.value = output.data;
146167
}
147168
} else {
148-
const output = await client.value?.reblogStatus(note.value.id);
169+
const output = await client.value?.reblogStatus(outputtedNote.value.id);
149170
150171
if (output?.data.reblog) {
151172
noteRef.value = output.data.reblog;

composables/EventBus.ts

+2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ type ApplicationEvents = {
2222
"composer:open": undefined;
2323
"composer:reply": Status;
2424
"composer:quote": Status;
25+
"composer:edit": Status;
2526
"composer:send": Status;
27+
"composer:send-edit": Status;
2628
"composer:close": undefined;
2729
"notification:new": NotificationEvent;
2830
"attachment:view": Attachment;

composables/NoteData.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ export const useNoteData = (
2222
false,
2323
);
2424
const mentions = useResolveMentions(
25-
renderedNote.value?.mentions ?? [],
25+
computed(() => renderedNote.value?.mentions ?? []),
2626
client.value,
2727
);
2828
const content = useParsedContent(
29-
renderedNote.value?.content ?? "",
30-
renderedNote.value?.emojis ?? [],
29+
computed(() => renderedNote.value?.content ?? ""),
30+
computed(() => renderedNote.value?.emojis ?? []),
3131
mentions,
3232
);
3333
const loaded = computed(() => content.value !== null);

composables/ParsedContent.ts

+9-5
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,21 @@ import MentionComponent from "../components/social-elements/notes/mention.vue";
1010
* @returns Reactive object with the parsed content
1111
*/
1212
export const useParsedContent = (
13-
content: string,
14-
emojis: Emoji[],
13+
content: MaybeRef<string>,
14+
emojis: MaybeRef<Emoji[]>,
1515
mentions: MaybeRef<Account[]> = ref([]),
1616
): Ref<string | null> => {
1717
const result = ref(null as string | null);
1818

1919
watch(
20-
mentions,
20+
isRef(content)
21+
? isRef(emojis)
22+
? [content, mentions, emojis]
23+
: [content, mentions]
24+
: mentions,
2125
async () => {
2226
const contentHtml = document.createElement("div");
23-
contentHtml.innerHTML = content;
27+
contentHtml.innerHTML = toValue(content);
2428

2529
// Replace emoji shortcodes with images
2630
const paragraphs = contentHtml.querySelectorAll("p");
@@ -29,7 +33,7 @@ export const useParsedContent = (
2933
paragraph.innerHTML = paragraph.innerHTML.replace(
3034
/:([a-z0-9_-]+):/g,
3135
(match, emoji) => {
32-
const emojiData = emojis.find(
36+
const emojiData = toValue(emojis).find(
3337
(e) => e.shortcode === emoji,
3438
);
3539
if (!emojiData) {

composables/ResolveMentions.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Account } from "~/types/mastodon/account";
33
import type { Mention } from "~/types/mastodon/mention";
44

55
export const useResolveMentions = (
6-
mentions: Mention[],
6+
mentions: Ref<Mention[]>,
77
client: Mastodon | null,
88
): Ref<Account[]> => {
99
if (!client) {
@@ -12,14 +12,14 @@ export const useResolveMentions = (
1212

1313
const output = ref<Account[]>([]);
1414

15-
(async () => {
15+
watch(mentions, async () => {
1616
output.value = await Promise.all(
17-
mentions.map(async (mention) => {
17+
toValue(mentions).map(async (mention) => {
1818
const response = await client.getAccount(mention.id);
1919
return response.data;
2020
}),
2121
);
22-
})();
22+
});
2323

2424
return output;
2525
};

0 commit comments

Comments
 (0)