Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/components/MeshCore/MeshCoreChannelsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,19 @@ export const MeshCoreChannelsView: React.FC<MeshCoreChannelsViewProps> = ({
const selfKey = status?.localNode?.publicKey;
const connected = status?.connected ?? false;

// Per-message delete + whole-channel clear (#3981). Both confirm first.
const handleDeleteMessage = useCallback(async (m: MeshCoreMessage) => {
if (!window.confirm(t('meshcore.confirm_delete_message', 'Delete this message?'))) return;
await actions.deleteMessage(m.id);
}, [actions, t]);
const handleClearChannel = useCallback(async () => {
if (!window.confirm(t(
'meshcore.confirm_clear_channel',
'Clear all messages on this channel? This cannot be undone.',
))) return;
await actions.clearChannelMessages(active.id);
}, [actions, active, t]);

const mobileClass = mobileShowContent ? 'mobile-show-content' : 'mobile-show-list';

return (
Expand Down Expand Up @@ -616,12 +629,25 @@ export const MeshCoreChannelsView: React.FC<MeshCoreChannelsViewProps> = ({
)}
</div>
)}
{canSend && filtered.length > 0 && (
<div className="meshcore-conversation-toolbar">
<button
type="button"
className="meshcore-clear-conversation-btn"
onClick={() => void handleClearChannel()}
title={t('meshcore.clear_channel', 'Clear channel messages')}
>
🗑️ {t('meshcore.clear_channel', 'Clear channel messages')}
</button>
</div>
)}
<MeshCoreMessageStream
messages={filtered}
contacts={contacts}
selfPublicKey={selfKey}
disabled={!connected || !canSend}
emptyText={t('meshcore.no_messages', 'No messages on this channel yet')}
onDeleteMessage={canSend ? handleDeleteMessage : undefined}
onSend={async text => {
// Pass the one-off scope override only when the operator has opened
// the control AND typed a value (incl. '' to mean unscoped). When
Expand Down
51 changes: 41 additions & 10 deletions src/components/MeshCore/MeshCoreDirectMessagesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,22 @@ export const MeshCoreDirectMessagesView: React.FC<MeshCoreDirectMessagesViewProp
if (isMobileViewport()) setMobileShowContent(true);
};

// Per-message delete + whole-conversation clear (#3981). Both confirm first
// (destructive, no undo); the hook prunes local state and the server
// broadcasts a deletion event so other clients converge.
const handleDeleteMessage = async (m: MeshCoreMessage) => {
if (!window.confirm(t('meshcore.confirm_delete_message', 'Delete this message?'))) return;
await actions.deleteMessage(m.id);
};
const handleClearConversation = async () => {
if (!selected) return;
if (!window.confirm(t(
'meshcore.confirm_clear_conversation',
'Clear the entire conversation with this contact? This cannot be undone.',
))) return;
await actions.clearConversation(selected);
};

const selectedContactName = selected
? (contactsByKey.get(selected)?.advName || contactsByKey.get(selected)?.name || `${selected.substring(0, 8)}…`)
: '';
Expand Down Expand Up @@ -480,16 +496,31 @@ export const MeshCoreDirectMessagesView: React.FC<MeshCoreDirectMessagesViewProp
{t('meshcore.repeater_no_messaging', 'Repeaters cannot receive direct messages — showing node details only.')}
</div>
) : (
<MeshCoreMessageStream
messages={filtered}
contacts={contacts}
selfPublicKey={selfKey}
disabled={!connected || !canSend}
emptyText={t('meshcore.no_messages', 'No messages with this contact yet')}
onSend={text => actions.sendMessage(text, selected)}
conversationKey={`dm-${selected}`}
maxBytes={150}
/>
<>
{canSend && filtered.length > 0 && (
<div className="meshcore-conversation-toolbar">
<button
type="button"
className="meshcore-clear-conversation-btn"
onClick={() => void handleClearConversation()}
title={t('meshcore.clear_conversation', 'Clear conversation')}
>
🗑️ {t('meshcore.clear_conversation', 'Clear conversation')}
</button>
</div>
)}
<MeshCoreMessageStream
messages={filtered}
contacts={contacts}
selfPublicKey={selfKey}
disabled={!connected || !canSend}
emptyText={t('meshcore.no_messages', 'No messages with this contact yet')}
onSend={text => actions.sendMessage(text, selected)}
onDeleteMessage={canSend ? handleDeleteMessage : undefined}
conversationKey={`dm-${selected}`}
maxBytes={150}
/>
</>
)}
<div className="meshcore-detail-pane">
<MeshCoreContactDetailPanel
Expand Down
15 changes: 15 additions & 0 deletions src/components/MeshCore/MeshCoreMessageStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ interface MeshCoreMessageStreamProps {
* stream prefills the composer with the `@[Sender]:` mention + focuses it;
* the parent uses this callback to send the reply on the message's scope. */
onReply?: (message: MeshCoreMessage) => void;
/** When provided, each message row shows a delete (🗑️) action (#3981). The
* parent is responsible for confirmation and the actual delete call. */
onDeleteMessage?: (message: MeshCoreMessage) => void | Promise<void>;
/** Stable key identifying the current conversation. When it changes, the
* stream re-runs its entry scroll (to the first-unread row, or the bottom). */
conversationKey?: string;
Expand Down Expand Up @@ -67,6 +70,7 @@ export const MeshCoreMessageStream: React.FC<MeshCoreMessageStreamProps> = ({
onSend,
onNodeNameClick,
onReply,
onDeleteMessage,
conversationKey,
maxBytes = 130,
}) => {
Expand Down Expand Up @@ -364,6 +368,17 @@ export const MeshCoreMessageStream: React.FC<MeshCoreMessageStreamProps> = ({
</button>
)}
{onDeleteMessage && (
<button
type="button"
className="mc-message-delete"
title={t('meshcore.delete_message', 'Delete this message')}
aria-label={t('meshcore.delete_message', 'Delete this message')}
onClick={() => { void onDeleteMessage(m); }}
>
🗑️
</button>
)}
</span>
</div>
<div className="mc-message-text">{renderMessageText(m.text)}</div>
Expand Down
48 changes: 48 additions & 0 deletions src/components/MeshCore/MeshCorePage.css
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,54 @@
.mc-message-row:hover .mc-message-reply { opacity: 1; }
.mc-message-reply:hover { color: var(--ctp-mauve); opacity: 1; }
@media (hover: none) { .mc-message-reply { opacity: 1; } }
/* Per-message delete affordance (#3981). Same subtle-until-hover treatment as
the reply button; turns red on hover to signal it's destructive. */
.mc-message-delete {
background: none;
border: none;
color: var(--ctp-subtext0);
cursor: pointer;
padding: 0 0.15rem;
font-size: 0.8rem;
opacity: 0.55;
transition: opacity 0.15s, color 0.15s;
}
.mc-message-row:hover .mc-message-delete { opacity: 1; }
.mc-message-delete:hover { color: var(--ctp-red); opacity: 1; }
@media (hover: none) { .mc-message-delete { opacity: 1; } }
/* Conversation / channel "clear" toolbar above the message stream (#3981). */
.meshcore-conversation-toolbar {
display: flex;
justify-content: flex-end;
padding: 0.35rem 0.5rem 0;
}
.meshcore-clear-conversation-btn {
background: none;
border: 1px solid var(--ctp-surface1);
color: var(--ctp-subtext0);
cursor: pointer;
border-radius: 6px;
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.meshcore-clear-conversation-btn:hover {
color: var(--ctp-red);
border-color: var(--ctp-red);
}
/* Prominent destructive action: purge every message for the source (#3981). */
.meshcore-purge-all-btn {
background: var(--ctp-red);
color: var(--ctp-base);
border: none;
border-radius: 6px;
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.meshcore-purge-all-btn:hover:not(:disabled) { filter: brightness(1.1); }
.meshcore-purge-all-btn:disabled { opacity: 0.6; cursor: default; }
.mc-message-text { color: var(--ctp-text); font-size: 0.9rem; word-wrap: break-word; }
/* Hop count + relay route for received messages (#3742). */
.mc-message-route { color: var(--ctp-subtext0); font-size: 0.75rem; margin-top: 0.15rem; font-family: var(--font-mono, monospace); }
Expand Down
47 changes: 47 additions & 0 deletions src/components/MeshCore/MeshCoreSettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useCallback, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ConnectionStatus, MeshCoreActions, SavedRegion } from './hooks/useMeshCore';
import { useToast } from '../ToastContainer';
import { useAuth } from '../../contexts/AuthContext';

// MeshCoreDeviceType.COMPANION — active discovery is companion-only.
const DEVICE_TYPE_COMPANION = 1;
Expand All @@ -19,6 +20,9 @@ export const MeshCoreSettingsView: React.FC<MeshCoreSettingsViewProps> = ({
}) => {
const { t } = useTranslation();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const canPurgeMessages = hasPermission('messages', 'write');
const [purgingMessages, setPurgingMessages] = useState(false);
const connected = status?.connected ?? false;
const isCompanion = status?.deviceType === DEVICE_TYPE_COMPANION;
// Which discovery (if any) is currently running, so we can disable both
Expand Down Expand Up @@ -56,6 +60,27 @@ export const MeshCoreSettingsView: React.FC<MeshCoreSettingsViewProps> = ({
if (rows) setSavedRegions(rows);
}, [fetchSavedRegions]);

// Purge every MeshCore message (channel + DM) for this source (#3981).
// Destructive and irreversible — double-confirm and surface the result.
const handlePurgeAllMessages = useCallback(async () => {
if (!window.confirm(t(
'meshcore.settings.confirm_purge_all_messages',
'Delete ALL MeshCore messages (every channel and DM) for this source? This cannot be undone.',
))) return;
setPurgingMessages(true);
try {
const ok = await actions.purgeAllMessages();
showToast(
ok
? t('meshcore.settings.purge_all_messages_done', 'All MeshCore messages purged')
: t('meshcore.settings.purge_all_messages_failed', 'Failed to purge messages'),
ok ? 'success' : 'error',
);
} finally {
setPurgingMessages(false);
}
}, [actions, showToast, t]);

useEffect(() => {
if (connected && isCompanion) {
void getDiscoverable().then(setDiscoverableState);
Expand Down Expand Up @@ -445,6 +470,28 @@ export const MeshCoreSettingsView: React.FC<MeshCoreSettingsViewProps> = ({
</div>
</div>
)}

{canPurgeMessages && (
<div className="form-section">
<h3>{t('meshcore.settings.message_data', 'Message data')}</h3>
<p style={{ color: 'var(--ctp-subtext0)', fontSize: '0.85rem', lineHeight: 1.6 }}>
{t(
'meshcore.settings.purge_all_messages_desc',
'Permanently delete every stored MeshCore message (all channels and direct messages) for this source.',
)}
</p>
<button
type="button"
className="meshcore-purge-all-btn"
onClick={() => void handlePurgeAllMessages()}
disabled={purgingMessages}
>
🗑️ {purgingMessages
? t('meshcore.settings.purging', 'Purging…')
: t('meshcore.settings.purge_all_messages', 'Purge all messages')}
</button>
</div>
)}
</div>
);
};
Loading
Loading