feat(post-menu): add Report/Delete, reorder buttons, edit-only-for-mods, copy link 5chan.app (#1089)

* Update crypto address UI for .bso accounts

* feat(post-menu): add Report/Delete, reorder buttons, edit-only-for-mods, copy link 5chan.app

- Remove redundant reason field from edit menu (purged posts unreachable)
- Add Report post (placeholder alert) and Delete post (mobile) to post menus
- Reorder: Report, Hide, Delete, Copy x3, Image search
- Mobile edit checkbox only for mods; authors use Delete in post menu
- Copy direct link always uses 5chan.app domain
- 10px margin-top on mod menu save button

* fix(post-menu): add confirmation before delete post (Bugbot)
This commit is contained in:
Tommaso Casaburi
2026-03-16 17:54:17 +08:00
committed by GitHub
parent 6ecf3c40d3
commit f4ff7a23f7
43 changed files with 299 additions and 84 deletions
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "مؤرشف", "archived": "مؤرشف",
"loading_archive": "تحميل الأرشيف...", "loading_archive": "تحميل الأرشيف...",
"thread_archived": "تم أرشفة الموضوع.", "thread_archived": "تم أرشفة الموضوع.",
"view": "عرض" "view": "عرض",
"report_post": "الإبلاغ عن المنشور",
"delete_post": "حذف المنشور",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "আর্কাইভ করা", "archived": "আর্কাইভ করা",
"loading_archive": "সংরক্ষণাগার লোড করা হচ্ছে...", "loading_archive": "সংরক্ষণাগার লোড করা হচ্ছে...",
"thread_archived": "থ্রেড আর্কাইভ করা হয়েছে।", "thread_archived": "থ্রেড আর্কাইভ করা হয়েছে।",
"view": "দেখুন" "view": "দেখুন",
"report_post": "পোস্ট রিপোর্ট করুন",
"delete_post": "পোস্ট মুছুন",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Archivováno", "archived": "Archivováno",
"loading_archive": "Načítání archivu...", "loading_archive": "Načítání archivu...",
"thread_archived": "Vlákno archivováno.", "thread_archived": "Vlákno archivováno.",
"view": "Zobrazit" "view": "Zobrazit",
"report_post": "Nahlásit příspěvek",
"delete_post": "Smazat příspěvek",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Arkiveret", "archived": "Arkiveret",
"loading_archive": "Indlæser arkiv...", "loading_archive": "Indlæser arkiv...",
"thread_archived": "Tråd arkiveret.", "thread_archived": "Tråd arkiveret.",
"view": "Se" "view": "Se",
"report_post": "Rapporter indlæg",
"delete_post": "Slet indlæg",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Archiviert", "archived": "Archiviert",
"loading_archive": "Archiv wird geladen...", "loading_archive": "Archiv wird geladen...",
"thread_archived": "Thread archiviert.", "thread_archived": "Thread archiviert.",
"view": "Ansehen" "view": "Ansehen",
"report_post": "Beitrag melden",
"delete_post": "Beitrag löschen",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Αρχειοθετημένο", "archived": "Αρχειοθετημένο",
"loading_archive": "Φόρτωση αρχείου...", "loading_archive": "Φόρτωση αρχείου...",
"thread_archived": "Το νήμα αρχειοθετήθηκε.", "thread_archived": "Το νήμα αρχειοθετήθηκε.",
"view": "Προβολή" "view": "Προβολή",
"report_post": "Αναφορά ανάρτησης",
"delete_post": "Διαγραφή ανάρτησης",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+3
View File
@@ -98,6 +98,9 @@
"invalid_url": "Invalid URL", "invalid_url": "Invalid URL",
"image_search": "image search", "image_search": "image search",
"hide_post": "Hide post", "hide_post": "Hide post",
"report_post": "Report post",
"delete_post": "Delete post",
"delete_post_confirm": "Delete this post permanently? This cannot be undone.",
"hide_thread": "Hide thread", "hide_thread": "Hide thread",
"unhide_post": "Unhide post", "unhide_post": "Unhide post",
"unhide_thread": "Unhide thread", "unhide_thread": "Unhide thread",
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Archivado", "archived": "Archivado",
"loading_archive": "Cargando archivo...", "loading_archive": "Cargando archivo...",
"thread_archived": "Hilo archivado.", "thread_archived": "Hilo archivado.",
"view": "Ver" "view": "Ver",
"report_post": "Denunciar publicación",
"delete_post": "Eliminar publicación",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "بایگانی شده", "archived": "بایگانی شده",
"loading_archive": "در حال بارگذاری بایگانی...", "loading_archive": "در حال بارگذاری بایگانی...",
"thread_archived": "موضوع بایگانی شد.", "thread_archived": "موضوع بایگانی شد.",
"view": "مشاهده" "view": "مشاهده",
"report_post": "گزارش پست",
"delete_post": "حذف پست",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Arkistoitu", "archived": "Arkistoitu",
"loading_archive": "Ladataan arkistoa...", "loading_archive": "Ladataan arkistoa...",
"thread_archived": "Keskustelu arkistoitu.", "thread_archived": "Keskustelu arkistoitu.",
"view": "Katso" "view": "Katso",
"report_post": "Ilmoita viestistä",
"delete_post": "Poista viesti",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Na-archive", "archived": "Na-archive",
"loading_archive": "Naglo-load ng arkibo...", "loading_archive": "Naglo-load ng arkibo...",
"thread_archived": "Thread na-archive na.", "thread_archived": "Thread na-archive na.",
"view": "Tingnan" "view": "Tingnan",
"report_post": "I-report ang post",
"delete_post": "Tanggalin ang post",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Archivé", "archived": "Archivé",
"loading_archive": "Chargement des archives...", "loading_archive": "Chargement des archives...",
"thread_archived": "Fil archivé.", "thread_archived": "Fil archivé.",
"view": "Voir" "view": "Voir",
"report_post": "Signaler la publication",
"delete_post": "Supprimer la publication",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "בארכיון", "archived": "בארכיון",
"loading_archive": "טוען ארכיון...", "loading_archive": "טוען ארכיון...",
"thread_archived": "השרשור נשמר בארכיון.", "thread_archived": "השרשור נשמר בארכיון.",
"view": "צפה" "view": "צפה",
"report_post": "דווח על פוסט",
"delete_post": "מחק פוסט",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "संग्रहीत", "archived": "संग्रहीत",
"loading_archive": "संग्रह लोड हो रहा है...", "loading_archive": "संग्रह लोड हो रहा है...",
"thread_archived": "थ्रेड आर्काइव हो गया।", "thread_archived": "थ्रेड आर्काइव हो गया।",
"view": "देखें" "view": "देखें",
"report_post": "पोस्ट की रिपोर्ट करें",
"delete_post": "पोस्ट हटाएं",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Archivált", "archived": "Archivált",
"loading_archive": "Archívum betöltése...", "loading_archive": "Archívum betöltése...",
"thread_archived": "A téma archiválva.", "thread_archived": "A téma archiválva.",
"view": "Megtekintés" "view": "Megtekintés",
"report_post": "Bejegyzés jelentése",
"delete_post": "Bejegyzés törlése",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Diarsipkan", "archived": "Diarsipkan",
"loading_archive": "Memuat arsip...", "loading_archive": "Memuat arsip...",
"thread_archived": "Benang diarsipkan.", "thread_archived": "Benang diarsipkan.",
"view": "Lihat" "view": "Lihat",
"report_post": "Laporkan kiriman",
"delete_post": "Hapus kiriman",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Archiviato", "archived": "Archiviato",
"loading_archive": "Caricamento archivio...", "loading_archive": "Caricamento archivio...",
"thread_archived": "Thread archiviato.", "thread_archived": "Thread archiviato.",
"view": "Visualizza" "view": "Visualizza",
"report_post": "Segnala post",
"delete_post": "Elimina post",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "アーカイブ済み", "archived": "アーカイブ済み",
"loading_archive": "アーカイブを読み込み中...", "loading_archive": "アーカイブを読み込み中...",
"thread_archived": "スレッドがアーカイブされました。", "thread_archived": "スレッドがアーカイブされました。",
"view": "表示" "view": "表示",
"report_post": "投稿を報告",
"delete_post": "投稿を削除",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "보관됨", "archived": "보관됨",
"loading_archive": "아카이브 로딩 중...", "loading_archive": "아카이브 로딩 중...",
"thread_archived": "스레드가 아카이브되었습니다.", "thread_archived": "스레드가 아카이브되었습니다.",
"view": "보기" "view": "보기",
"report_post": "게시물 신고",
"delete_post": "게시물 삭제",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "संग्रहित", "archived": "संग्रहित",
"loading_archive": "संग्रह लोड करत आहे...", "loading_archive": "संग्रह लोड करत आहे...",
"thread_archived": "सूत्र आर्काइव केले गेले.", "thread_archived": "सूत्र आर्काइव केले गेले.",
"view": "पहा" "view": "पहा",
"report_post": "पोस्ट निर्वेदन करा",
"delete_post": "पोस्ट हटवा",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Gearchiveerd", "archived": "Gearchiveerd",
"loading_archive": "Archief laden...", "loading_archive": "Archief laden...",
"thread_archived": "Thread gearchiveerd.", "thread_archived": "Thread gearchiveerd.",
"view": "Bekijken" "view": "Bekijken",
"report_post": "Bericht melden",
"delete_post": "Bericht verwijderen",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Arkivert", "archived": "Arkivert",
"loading_archive": "Laster arkiv...", "loading_archive": "Laster arkiv...",
"thread_archived": "Tråd arkivert.", "thread_archived": "Tråd arkivert.",
"view": "Se" "view": "Se",
"report_post": "Rapporter innlegg",
"delete_post": "Slett innlegg",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Zarchiwizowany", "archived": "Zarchiwizowany",
"loading_archive": "Ładowanie archiwum...", "loading_archive": "Ładowanie archiwum...",
"thread_archived": "Wątek zarchiwizowany.", "thread_archived": "Wątek zarchiwizowany.",
"view": "Zobacz" "view": "Zobacz",
"report_post": "Zgłoś post",
"delete_post": "Usuń post",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Arquivado", "archived": "Arquivado",
"loading_archive": "Carregando arquivo...", "loading_archive": "Carregando arquivo...",
"thread_archived": "Tópico arquivado.", "thread_archived": "Tópico arquivado.",
"view": "Ver" "view": "Ver",
"report_post": "Denunciar publicação",
"delete_post": "Excluir publicação",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Arhivat", "archived": "Arhivat",
"loading_archive": "Încărcare arhivă...", "loading_archive": "Încărcare arhivă...",
"thread_archived": "Thread arhivat.", "thread_archived": "Thread arhivat.",
"view": "Vizualizare" "view": "Vizualizare",
"report_post": "Raportează postarea",
"delete_post": "Șterge postarea",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Архивировано", "archived": "Архивировано",
"loading_archive": "Загрузка архива...", "loading_archive": "Загрузка архива...",
"thread_archived": "Тема архивирована.", "thread_archived": "Тема архивирована.",
"view": "Смотреть" "view": "Смотреть",
"report_post": "Пожаловаться на пост",
"delete_post": "Удалить пост",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Arkivuar", "archived": "Arkivuar",
"loading_archive": "Po ngarkoni arkivin...", "loading_archive": "Po ngarkoni arkivin...",
"thread_archived": "Fjala është arkivuar.", "thread_archived": "Fjala është arkivuar.",
"view": "Shiko" "view": "Shiko",
"report_post": "Raporto postimin",
"delete_post": "Fshi postimin",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Arkiverad", "archived": "Arkiverad",
"loading_archive": "Laddar arkiv...", "loading_archive": "Laddar arkiv...",
"thread_archived": "Tråd arkiverad.", "thread_archived": "Tråd arkiverad.",
"view": "Visa" "view": "Visa",
"report_post": "Rapportera inlägg",
"delete_post": "Radera inlägg",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "ఆర్కైవ్ చేయబడింది", "archived": "ఆర్కైవ్ చేయబడింది",
"loading_archive": "ఆర్కైవ్ లోడ్ అవుతోంది...", "loading_archive": "ఆర్కైవ్ లోడ్ అవుతోంది...",
"thread_archived": "థ్రెడ్ ఆర్కైవ్ చేయబడింది.", "thread_archived": "థ్రెడ్ ఆర్కైవ్ చేయబడింది.",
"view": "చూడు" "view": "చూడు",
"report_post": "పోస్ట్ రిపోర్ట్ చేయండి",
"delete_post": "పోస్ట్ తొలగించండి",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "เก็บถาวร", "archived": "เก็บถาวร",
"loading_archive": "กำลังโหลดเก็บถาวร...", "loading_archive": "กำลังโหลดเก็บถาวร...",
"thread_archived": "เธรดถูกเก็บถาวร", "thread_archived": "เธรดถูกเก็บถาวร",
"view": "ดู" "view": "ดู",
"report_post": "แจ้งรายงานโพสต์",
"delete_post": "ลบโพสต์",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Arşivlendi", "archived": "Arşivlendi",
"loading_archive": "Arşiv yükleniyor...", "loading_archive": "Arşiv yükleniyor...",
"thread_archived": "Konu arşivlendi.", "thread_archived": "Konu arşivlendi.",
"view": "Görüntüle" "view": "Görüntüle",
"report_post": "Gönderiyi bildir",
"delete_post": "Gönderiyi sil",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Архівовано", "archived": "Архівовано",
"loading_archive": "Завантаження архіву...", "loading_archive": "Завантаження архіву...",
"thread_archived": "Тему архівовано.", "thread_archived": "Тему архівовано.",
"view": "Переглянути" "view": "Переглянути",
"report_post": "Поскаржитися на пост",
"delete_post": "Видалити пост",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "محفوظ شدہ", "archived": "محفوظ شدہ",
"loading_archive": "آرکائیو لوڈ ہو رہا ہے...", "loading_archive": "آرکائیو لوڈ ہو رہا ہے...",
"thread_archived": "موضوع آرکائیو کر دیا گیا ہے۔", "thread_archived": "موضوع آرکائیو کر دیا گیا ہے۔",
"view": "دیکھیں" "view": "دیکھیں",
"report_post": "پوسٹ ریپورٹ کریں",
"delete_post": "پوسٹ حذف کریں",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "Đã lưu trữ", "archived": "Đã lưu trữ",
"loading_archive": "Đang tải lưu trữ...", "loading_archive": "Đang tải lưu trữ...",
"thread_archived": "Chủ đề đã được lưu trữ.", "thread_archived": "Chủ đề đã được lưu trữ.",
"view": "Xem" "view": "Xem",
"report_post": "Báo cáo bài viết",
"delete_post": "Xóa bài viết",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
+4 -1
View File
@@ -314,5 +314,8 @@
"archived": "已归档", "archived": "已归档",
"loading_archive": "正在加载归档...", "loading_archive": "正在加载归档...",
"thread_archived": "主题已归档。", "thread_archived": "主题已归档。",
"view": "查看" "view": "查看",
"report_post": "举报帖子",
"delete_post": "删除帖子",
"delete_post_confirm": "Delete this post permanently? This cannot be undone."
} }
@@ -128,7 +128,6 @@ const basePost = {
parentCid: undefined as string | undefined, parentCid: undefined as string | undefined,
pinned: false, pinned: false,
postCid: 'post-1', postCid: 'post-1',
reason: '',
removed: false, removed: false,
spoiler: false, spoiler: false,
communityAddress: 'music-posting.eth', communityAddress: 'music-posting.eth',
@@ -228,7 +227,7 @@ describe('EditMenu', () => {
expect(alertSpy).toHaveBeenLastCalledWith('cannot_edit_reply'); expect(alertSpy).toHaveBeenLastCalledWith('cannot_edit_reply');
}); });
it('lets comment authors update content, deletion, and reason', async () => { it('lets comment authors update content and deletion', async () => {
testState.privileges = { testState.privileges = {
isAccountCommentAuthor: true, isAccountCommentAuthor: true,
isAccountMod: false, isAccountMod: false,
@@ -242,12 +241,9 @@ describe('EditMenu', () => {
await click(getLabelCheckbox('Edit?')); await click(getLabelCheckbox('Edit?'));
const textarea = container.querySelector('textarea'); const textarea = container.querySelector('textarea');
const reasonInput = container.querySelector<HTMLInputElement>('input[type="text"]');
expect(textarea).not.toBeNull(); expect(textarea).not.toBeNull();
expect(reasonInput).not.toBeNull();
await dispatchInput(textarea as HTMLTextAreaElement, 'Updated body'); await dispatchInput(textarea as HTMLTextAreaElement, 'Updated body');
await dispatchInput(reasonInput as HTMLInputElement, 'cleanup');
await clickButton('save'); await clickButton('save');
expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce(); expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce();
@@ -260,7 +256,6 @@ describe('EditMenu', () => {
commentCid: 'comment-1', commentCid: 'comment-1',
content: 'Updated body', content: 'Updated body',
deleted: true, deleted: true,
reason: 'cleanup',
spoiler: false, spoiler: false,
communityAddress: 'music-posting.eth', communityAddress: 'music-posting.eth',
}); });
@@ -343,12 +338,9 @@ describe('EditMenu', () => {
await click(getCheckbox('banUser')); await click(getCheckbox('banUser'));
const banDurationInput = container.querySelector<HTMLInputElement>('[data-testid="ban-duration-input"]'); const banDurationInput = container.querySelector<HTMLInputElement>('[data-testid="ban-duration-input"]');
const reasonInput = container.querySelector<HTMLInputElement>('input[type="text"]');
expect(banDurationInput).not.toBeNull(); expect(banDurationInput).not.toBeNull();
expect(reasonInput).not.toBeNull();
await dispatchInput(banDurationInput as HTMLInputElement, '7'); await dispatchInput(banDurationInput as HTMLInputElement, '7');
await dispatchInput(reasonInput as HTMLInputElement, 'rule violation');
await clickButton('save'); await clickButton('save');
expect(confirmSpy).toHaveBeenCalledWith('purge_confirm'); expect(confirmSpy).toHaveBeenCalledWith('purge_confirm');
@@ -363,7 +355,6 @@ describe('EditMenu', () => {
communityAddress: 'music-posting.eth', communityAddress: 'music-posting.eth',
}); });
expect(testState.modOptions?.commentModeration).toMatchObject({ expect(testState.modOptions?.commentModeration).toMatchObject({
reason: 'rule violation',
archived: true, archived: true,
locked: true, locked: true,
pinned: true, pinned: true,
@@ -74,6 +74,7 @@
.bottom button { .bottom button {
text-transform: capitalize; text-transform: capitalize;
margin-left: 2px; margin-left: 2px;
margin-top: 10px;
margin-bottom: 2px; margin-bottom: 2px;
} }
@@ -90,7 +91,8 @@
@media (min-width: 640px) { @media (min-width: 640px) {
.bottom button { .bottom button {
cursor: pointer; cursor: pointer;
padding: 2px 6px 3px; margin-top: 10px;
padding: 2px 6px 3px;
} }
} }
+1 -17
View File
@@ -36,7 +36,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const resolvedPost = withResolvedCommentCommunityAddress(post); const resolvedPost = withResolvedCommentCommunityAddress(post);
const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler } = resolvedPost || {}; const { author, cid, content, deleted, locked, parentCid, pinned, postCid, removed, spoiler } = resolvedPost || {};
const communityAddress = getCommentCommunityAddress(resolvedPost); const communityAddress = getCommentCommunityAddress(resolvedPost);
const archived = isCommentArchived(resolvedPost); const archived = isCommentArchived(resolvedPost);
const authorDisplayName = resolvedPost?.author?.displayName; const authorDisplayName = resolvedPost?.author?.displayName;
@@ -80,7 +80,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
removed: removed ?? false, removed: removed ?? false,
purged: purged ?? false, purged: purged ?? false,
spoiler: spoiler ?? false, spoiler: spoiler ?? false,
reason,
author: modBanExpiresAt ? { banExpiresAt: modBanExpiresAt } : undefined, author: modBanExpiresAt ? { banExpiresAt: modBanExpiresAt } : undefined,
} }
: undefined, : undefined,
@@ -101,7 +100,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
deleted, deleted,
locked, locked,
pinned, pinned,
reason,
removed, removed,
purged, purged,
spoiler, spoiler,
@@ -118,7 +116,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
communityAddress, communityAddress,
content: publishCommentEditOptions.content, content: publishCommentEditOptions.content,
deleted: publishCommentEditOptions.deleted, deleted: publishCommentEditOptions.deleted,
reason: publishCommentEditOptions.reason,
spoiler: publishCommentEditOptions.spoiler, spoiler: publishCommentEditOptions.spoiler,
onChallenge, onChallenge,
onChallengeVerification: alertChallengeVerificationFailed, onChallengeVerification: alertChallengeVerificationFailed,
@@ -150,7 +147,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
removed: publishCommentEditOptions.commentModeration?.removed, removed: publishCommentEditOptions.commentModeration?.removed,
purged: publishCommentEditOptions.commentModeration?.purged, purged: publishCommentEditOptions.commentModeration?.purged,
spoiler: publishCommentEditOptions.commentModeration?.spoiler, spoiler: publishCommentEditOptions.commentModeration?.spoiler,
reason: publishCommentEditOptions.reason,
author: publishCommentEditOptions.commentModeration?.author, author: publishCommentEditOptions.commentModeration?.author,
}, },
author: account?.author, author: account?.author,
@@ -436,18 +432,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
)} )}
</> </>
)} )}
<div className={`${styles.menuItem} ${styles.menuReason}`}>
{capitalize(t('reason'))}? ({t('optional')})
<input
type='text'
value={publishCommentEditOptions.reason || ''}
onChange={(e) => {
const newReason = e.target.value;
setPublishCommentEditOptions((state) => ({ ...state, reason: newReason }));
}}
size={14}
/>
</div>
<div className={styles.bottom}> <div className={styles.bottom}>
<button className={isMobile ? 'button' : ''} onClick={_publishCommentEdit} disabled={!canSave}> <button className={isMobile ? 'button' : ''} onClick={_publishCommentEdit} disabled={!canSave}>
{t('save')} {t('save')}
@@ -229,9 +229,24 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
createPortal( createPortal(
<FloatingFocusManager context={context} modal={false}> <FloatingFocusManager context={context} modal={false}>
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}> <div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
{cid && resolvedCommunityAddress && <CopyLinkButton cid={cid} communityAddress={resolvedCommunityAddress} linkType='thread' onClose={handleClose} />} <div
{cid && <CopyContentIdButton cid={cid} onClose={handleClose} />} className={styles.postMenuItem}
{authorAddress && <CopyUserIdButton address={authorAddress} onClose={handleClose} />} role='button'
tabIndex={0}
onClick={() => {
alert("Reporting isn't available yet.");
handleClose();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
alert("Reporting isn't available yet.");
handleClose();
}
}}
>
{t('report_post')}
</div>
{!(isInPostPageView && postCid === cid) && ( {!(isInPostPageView && postCid === cid) && (
<div <div
className={styles.postMenuItem} className={styles.postMenuItem}
@@ -252,6 +267,9 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
{hidden ? (postCid === cid ? t('unhide_thread') : t('unhide_post')) : postCid === cid ? t('hide_thread') : t('hide_post')} {hidden ? (postCid === cid ? t('unhide_thread') : t('unhide_post')) : postCid === cid ? t('hide_thread') : t('hide_post')}
</div> </div>
)} )}
{cid && resolvedCommunityAddress && <CopyLinkButton cid={cid} communityAddress={resolvedCommunityAddress} linkType='thread' onClose={handleClose} />}
{cid && <CopyContentIdButton cid={cid} onClose={handleClose} />}
{authorAddress && <CopyUserIdButton address={authorAddress} onClose={handleClose} />}
{link && isValidURL(link) && (type === 'image' || type === 'gif' || thumbnail) && url && <ImageSearchButton url={url} onClose={handleClose} />} {link && isValidURL(link) && (type === 'image' || type === 'gif' || thumbnail) && url && <ImageSearchButton url={url} onClose={handleClose} />}
</div> </div>
</FloatingFocusManager>, </FloatingFocusManager>,
@@ -29,7 +29,10 @@ vi.mock('react-i18next', () => ({
}), }),
})); }));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({})); vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useAccount: () => ({ author: { address: '0xmod' }, signer: { address: '0xauthor' } }),
usePublishCommentEdit: () => ({ publishCommentEdit: vi.fn().mockResolvedValue(undefined) }),
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({ vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({
default: { default: {
@@ -172,10 +175,10 @@ describe('PostMenuMobile', () => {
container.remove(); container.remove();
}); });
it('opens the mobile menu, copies share metadata, and shows edit controls for privileged users', async () => { it('opens the mobile menu, copies share metadata, and shows edit controls for mods', async () => {
testState.privileges = { testState.privileges = {
isAccountCommentAuthor: true, isAccountCommentAuthor: true,
isAccountMod: false, isAccountMod: true,
}; };
await renderMenu('/mu'); await renderMenu('/mu');
@@ -203,12 +206,16 @@ describe('PostMenuMobile', () => {
expect(testState.hideMock).toHaveBeenCalled(); expect(testState.hideMock).toHaveBeenCalled();
}); });
it('shows edit controls on pseudonymous boards even without a local author-address match', async () => { it('does not show edit controls on pseudonymous boards when user is not a mod', async () => {
testState.pseudonymityMode = 'per-post'; testState.pseudonymityMode = 'per-post';
testState.privileges = {
isAccountCommentAuthor: false,
isAccountMod: false,
};
await renderMenu('/mu'); await renderMenu('/mu');
expect(document.body.querySelector('[data-testid="edit-menu"]')?.textContent).toBe('cid-1'); expect(document.body.querySelector('[data-testid="edit-menu"]')).toBeNull();
}); });
it("does not show edit controls when pseudonymity mode is 'none' and the user lacks privileges", async () => { it("does not show edit controls when pseudonymity mode is 'none' and the user lacks privileges", async () => {
@@ -1,7 +1,8 @@
import { memo, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { Comment } from '@bitsocialnet/bitsocial-react-hooks'; import type { ChallengeVerification, Comment } from '@bitsocialnet/bitsocial-react-hooks';
import { useAccount, usePublishCommentEdit } from '@bitsocialnet/bitsocial-react-hooks';
import { autoUpdate, flip, FloatingFocusManager, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react'; import { autoUpdate, flip, FloatingFocusManager, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
import styles from './post-menu-mobile.module.css'; import styles from './post-menu-mobile.module.css';
import { getCommentMediaInfo } from '../../../lib/utils/media-utils'; import { getCommentMediaInfo } from '../../../lib/utils/media-utils';
@@ -16,6 +17,9 @@ import EditMenu from '../../edit-menu/edit-menu';
import { isBoardView, isPostPageView } from '../../../lib/utils/view-utils'; import { isBoardView, isPostPageView } from '../../../lib/utils/view-utils';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { PostMenuProps } from '../../../lib/utils/post-menu-props'; import { PostMenuProps } from '../../../lib/utils/post-menu-props';
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../../lib/utils/comment-utils';
import { alertChallengeVerificationFailed } from '../../../lib/utils/challenge-utils';
import useChallengesStore from '../../../stores/use-challenges-store';
async function copyShareLinkSafe(boardIdentifier: string, linkType: ShareLinkType, cid?: string): Promise<void> { async function copyShareLinkSafe(boardIdentifier: string, linkType: ShareLinkType, cid?: string): Promise<void> {
try { try {
@@ -154,6 +158,113 @@ const ImageSearchButtons = ({ url, onClose }: { url: string; onClose: () => void
); );
}; };
const { addChallenge } = useChallengesStore.getState();
const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
const handleClick = () => {
alert("Reporting isn't available yet.");
onClose();
};
return (
<div
role='button'
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
<div className={styles.postMenuItem}>{t('report_post')}</div>
</div>
);
};
type DeletePostButtonProps = {
post: Comment;
onClose: () => void;
};
const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
const { t } = useTranslation();
const account = useAccount();
const resolvedPost = withResolvedCommentCommunityAddress(post);
const { author, cid } = resolvedPost || {};
const communityAddress = getCommentCommunityAddress(resolvedPost);
const { isAccountCommentAuthor } = useEditCommentPrivileges({
commentAuthorAddress: author?.address || '',
communityAddress: communityAddress || '',
postCid: resolvedPost?.postCid,
});
const signer = isAccountCommentAuthor ? account?.signer : undefined;
const latestPostRef = useRef(resolvedPost);
useEffect(() => {
latestPostRef.current = resolvedPost;
}, [resolvedPost]);
const onChallenge = useCallback(async (...args: unknown[]) => {
addChallenge([...args, latestPostRef.current]);
}, []);
const deleteOptions = useMemo(
() => ({
commentCid: cid,
communityAddress,
deleted: true,
...(isAccountCommentAuthor && signer
? {
signer,
author: signer?.address === author?.address ? { address: signer.address, displayName: resolvedPost?.author?.displayName } : account?.author,
}
: {}),
onChallenge,
onChallengeVerification: async (challengeVerification: ChallengeVerification, publication: unknown) => {
alertChallengeVerificationFailed(challengeVerification, publication);
},
onError: (error: Error) => {
console.warn(error);
alert('Comment edit failed. ' + error.message);
},
}),
[cid, communityAddress, isAccountCommentAuthor, signer, author?.address, resolvedPost?.author?.displayName, account?.author, onChallenge],
);
const { publishCommentEdit } = usePublishCommentEdit(deleteOptions);
const handleClick = async () => {
const confirmed = window.confirm(t('delete_post_confirm'));
if (!confirmed) {
return;
}
try {
await publishCommentEdit();
onClose();
} catch (error) {
if (error instanceof Error) {
alert(error.message);
}
}
};
return (
<div
role='button'
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
<div className={styles.postMenuItem}>{t('delete_post')}</div>
</div>
);
};
const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) => { const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { hide, hidden, unhide } = useHide({ cid: cid || '' }); const { hide, hidden, unhide } = useHide({ cid: cid || '' });
@@ -253,10 +364,12 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
createPortal( createPortal(
<FloatingFocusManager context={context} modal={false}> <FloatingFocusManager context={context} modal={false}>
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}> <div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
<ReportPostButton onClose={handleClose} />
{cid && resolvedCommunityAddress && <HidePostButton cid={cid} isReply={!!parentCid} postCid={postCid} onClose={handleClose} />}
{(isAccountCommentAuthor || canAttemptAuthorDelete) && cid && <DeletePostButton post={editMenuPost} onClose={handleClose} />}
{cid && resolvedCommunityAddress && <CopyLinkButton cid={cid} communityAddress={resolvedCommunityAddress} linkType='thread' onClose={handleClose} />} {cid && resolvedCommunityAddress && <CopyLinkButton cid={cid} communityAddress={resolvedCommunityAddress} linkType='thread' onClose={handleClose} />}
{cid && <CopyContentIdButton cid={cid} onClose={handleClose} />} {cid && <CopyContentIdButton cid={cid} onClose={handleClose} />}
{authorAddress && <CopyUserIdButton address={authorAddress} onClose={handleClose} />} {authorAddress && <CopyUserIdButton address={authorAddress} onClose={handleClose} />}
{cid && resolvedCommunityAddress && <HidePostButton cid={cid} isReply={!!parentCid} postCid={postCid} onClose={handleClose} />}
{link && isValidURL(link) && (type === 'image' || type === 'gif' || thumbnail) && url && <ImageSearchButtons url={url} onClose={handleClose} />} {link && isValidURL(link) && (type === 'image' || type === 'gif' || thumbnail) && url && <ImageSearchButtons url={url} onClose={handleClose} />}
</div> </div>
</FloatingFocusManager>, </FloatingFocusManager>,
@@ -264,7 +377,7 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
)} )}
</> </>
)} )}
{(isAccountMod || isAccountCommentAuthor || canAttemptAuthorDelete) && cid && ( {isAccountMod && cid && (
<span className={styles.checkbox}> <span className={styles.checkbox}>
<EditMenu post={editMenuPost} /> <EditMenu post={editMenuPost} />
</span> </span>
@@ -126,7 +126,7 @@ const CryptoAddressSetting = () => {
<div className={styles.cryptoAddressInput}> <div className={styles.cryptoAddressInput}>
<input <input
type='text' type='text'
placeholder='myaddress.eth' placeholder='myaddress.bso'
value={cryptoState.cryptoAddress} value={cryptoState.cryptoAddress}
onChange={(e) => { onChange={(e) => {
setInputValue(e.target.value); setInputValue(e.target.value);
@@ -141,18 +141,18 @@ const CryptoAddressSetting = () => {
</button> </button>
{showCryptoAddressInfo && ( {showCryptoAddressInfo && (
<div className={styles.cryptoAddressInfo}> <div className={styles.cryptoAddressInfo}>
steps to set a .eth address as your ID: steps to set a .bso account address:
<br /> <br />
<ol> <ol>
<li> <li>
go to{' '} buy your desired .bso address as .eth on{' '}
<a href='https://app.ens.domains/' target='_blank' rel='noopener noreferrer'> <a href='https://app.ens.domains/' target='_blank' rel='noopener noreferrer'>
app.ens.domains app.ens.domains
</a>{' '} </a>{' '}
and search the address
</li> </li>
<li>once you own the address, go to its page, click on "records", then "edit records"</li> <li>once you own the .eth address, go to its page on ENS, click on "records", then "edit records"</li>
<li>add a new text record with name "bitsocial" and value: {account?.signer?.address}</li> <li>add a new text record with name "bitsocial" and value: {account?.signer?.address}</li>
<li>enter your .bso address in the input field above, click "check" to verify it's yours, then click "save"</li>
</ol> </ol>
</div> </div>
)} )}
-5
View File
@@ -22,11 +22,6 @@ export const isValidURL = (url: string) => {
const CHAN_5_HOSTNAMES = ['5chan.app', '5chan.eth.limo', '5chan.eth.link', '5chan.eth.sucks', '5chan.netlify.app']; const CHAN_5_HOSTNAMES = ['5chan.app', '5chan.eth.limo', '5chan.eth.link', '5chan.eth.sucks', '5chan.netlify.app'];
function getShareBaseUrl(): string { function getShareBaseUrl(): string {
const { protocol, hostname, origin } = window.location;
if ((protocol === 'https:' || protocol === 'http:') && hostname !== 'localhost' && hostname !== '127.0.0.1') {
return origin;
}
// Electron / Capacitor / local dev fallback
return `https://${CHAN_5_HOSTNAMES[0]}`; return `https://${CHAN_5_HOSTNAMES[0]}`;
} }