mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-04-30 18:42:51 +02:00
Add channel path hash width override
This commit is contained in:
@@ -353,6 +353,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels |
|
||||
| DELETE | `/api/channels/{key}` | Delete channel |
|
||||
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
|
||||
| POST | `/api/channels/{key}/path-hash-mode-override` | Set or clear a per-channel path hash mode override |
|
||||
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
||||
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
|
||||
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
|
||||
@@ -401,6 +402,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
|
||||
- Custom channels: User-provided or generated
|
||||
- Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting.
|
||||
- Channels may persist `path_hash_mode_override` (0/1/2); when set, channel sends temporarily switch the radio path hash mode for the duration of the send, then restore the radio default.
|
||||
|
||||
### Message Types
|
||||
|
||||
|
||||
@@ -218,6 +218,7 @@ app/
|
||||
- `POST /channels/bulk-hashtag`
|
||||
- `DELETE /channels/{key}`
|
||||
- `POST /channels/{key}/flood-scope-override`
|
||||
- `POST /channels/{key}/path-hash-mode-override`
|
||||
- `POST /channels/{key}/mark-read`
|
||||
|
||||
### Messages
|
||||
@@ -280,7 +281,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
||||
Main tables:
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
|
||||
- `channels`
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends.
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends and optional `path_hash_mode_override` for per-channel path hop width.
|
||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||
- `raw_packets`
|
||||
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
|
||||
|
||||
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
is_hashtag INTEGER DEFAULT 0,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
flood_scope_override TEXT,
|
||||
path_hash_mode_override INTEGER,
|
||||
last_read_at INTEGER
|
||||
);
|
||||
|
||||
|
||||
@@ -395,6 +395,12 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 51)
|
||||
applied += 1
|
||||
|
||||
if version < 52:
|
||||
logger.info("Applying migration 52: add path_hash_mode_override to channels")
|
||||
await _migrate_052_add_channel_path_hash_mode_override(conn)
|
||||
await set_version(conn, 52)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -3149,3 +3155,19 @@ async def _migrate_051_drop_sidebar_sort_order(conn: aiosqlite.Connection) -> No
|
||||
await conn.commit()
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def _migrate_052_add_channel_path_hash_mode_override(conn: aiosqlite.Connection) -> None:
|
||||
"""Add nullable per-channel path hash mode override column."""
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
if "channels" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||
await conn.commit()
|
||||
return
|
||||
try:
|
||||
await conn.execute("ALTER TABLE channels ADD COLUMN path_hash_mode_override INTEGER")
|
||||
await conn.commit()
|
||||
except Exception as e:
|
||||
if "duplicate column" in str(e).lower():
|
||||
await conn.commit()
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -330,6 +330,10 @@ class Channel(BaseModel):
|
||||
default=None,
|
||||
description="Per-channel outbound flood scope override (null = use global app setting)",
|
||||
)
|
||||
path_hash_mode_override: int | None = Field(
|
||||
default=None,
|
||||
description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
|
||||
)
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class ChannelRepository:
|
||||
"""Get a channel by its key (32-char hex string)."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
|
||||
FROM channels
|
||||
WHERE key = ?
|
||||
""",
|
||||
@@ -40,6 +40,7 @@ class ChannelRepository:
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
return None
|
||||
@@ -48,7 +49,7 @@ class ChannelRepository:
|
||||
async def get_all() -> list[Channel]:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
|
||||
FROM channels
|
||||
ORDER BY name
|
||||
"""
|
||||
@@ -61,6 +62,7 @@ class ChannelRepository:
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
@@ -71,7 +73,7 @@ class ChannelRepository:
|
||||
"""Return channels currently marked as resident on the radio in the database."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
|
||||
FROM channels
|
||||
WHERE on_radio = 1
|
||||
ORDER BY name
|
||||
@@ -85,6 +87,7 @@ class ChannelRepository:
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
@@ -123,6 +126,16 @@ class ChannelRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def update_path_hash_mode_override(key: str, path_hash_mode_override: int | None) -> bool:
|
||||
"""Set or clear a channel's path hash mode override."""
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET path_hash_mode_override = ? WHERE key = ?",
|
||||
(path_hash_mode_override, key.upper()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all channels as read at the given timestamp."""
|
||||
|
||||
@@ -60,6 +60,15 @@ class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ChannelPathHashModeOverrideRequest(BaseModel):
|
||||
path_hash_mode_override: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
le=2,
|
||||
description="Path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
|
||||
)
|
||||
|
||||
|
||||
def _derive_channel_identity(
|
||||
requested_name: str,
|
||||
request_key: str | None = None,
|
||||
@@ -348,6 +357,29 @@ async def set_channel_flood_scope_override(
|
||||
return refreshed
|
||||
|
||||
|
||||
@router.post("/{key}/path-hash-mode-override", response_model=Channel)
|
||||
async def set_channel_path_hash_mode_override(
|
||||
key: str, request: ChannelPathHashModeOverrideRequest
|
||||
) -> Channel:
|
||||
"""Set or clear a per-channel path hash mode override."""
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
updated = await ChannelRepository.update_path_hash_mode_override(
|
||||
channel.key, request.path_hash_mode_override
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=500, detail="Failed to update path-hash-mode override")
|
||||
|
||||
refreshed = await ChannelRepository.get_by_key(channel.key)
|
||||
if refreshed is None:
|
||||
raise HTTPException(status_code=500, detail="Channel disappeared after update")
|
||||
|
||||
broadcast_event("channel", refreshed.model_dump())
|
||||
return refreshed
|
||||
|
||||
|
||||
@router.delete("/{key}")
|
||||
async def delete_channel(key: str) -> dict:
|
||||
"""Delete a channel from the database by key.
|
||||
|
||||
@@ -122,7 +122,7 @@ async def send_channel_message_with_effective_scope(
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
app_settings_repository=AppSettingsRepository,
|
||||
) -> Any:
|
||||
"""Send a channel message, temporarily overriding flood scope when configured."""
|
||||
"""Send a channel message, temporarily overriding flood scope and/or path hash mode."""
|
||||
override_scope = normalize_region_scope(channel.flood_scope_override)
|
||||
baseline_scope = ""
|
||||
|
||||
@@ -151,6 +151,36 @@ async def send_channel_message_with_effective_scope(
|
||||
),
|
||||
)
|
||||
|
||||
# Path hash mode per-channel override
|
||||
override_phm = channel.path_hash_mode_override
|
||||
baseline_phm = radio_manager.path_hash_mode
|
||||
apply_phm = (
|
||||
override_phm is not None
|
||||
and radio_manager.path_hash_mode_supported
|
||||
and override_phm != baseline_phm
|
||||
)
|
||||
|
||||
if apply_phm:
|
||||
logger.info(
|
||||
"Temporarily applying channel path_hash_mode override for %s: %d",
|
||||
channel.name,
|
||||
override_phm,
|
||||
)
|
||||
phm_result = await mc.commands.set_path_hash_mode(override_phm)
|
||||
if phm_result is not None and phm_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to apply channel path_hash_mode override for %s: %s",
|
||||
channel.name,
|
||||
phm_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
f"Failed to apply path hash mode override before {action_label}: "
|
||||
f"{phm_result.payload}"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
|
||||
channel_key,
|
||||
@@ -254,6 +284,43 @@ async def send_channel_message_with_effective_scope(
|
||||
),
|
||||
)
|
||||
|
||||
if apply_phm:
|
||||
try:
|
||||
restore_phm = await mc.commands.set_path_hash_mode(baseline_phm)
|
||||
if restore_phm is not None and restore_phm.type == EventType.ERROR:
|
||||
logger.error(
|
||||
"Failed to restore baseline path_hash_mode after sending to %s: %s",
|
||||
channel.name,
|
||||
restore_phm.payload,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Path hash mode restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring path hash mode failed. "
|
||||
"The radio may be using a non-default hop width. "
|
||||
"Consider rebooting the radio."
|
||||
),
|
||||
)
|
||||
else:
|
||||
radio_manager.path_hash_mode = baseline_phm
|
||||
logger.debug(
|
||||
"Restored baseline path_hash_mode after channel send: %d",
|
||||
baseline_phm,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to restore baseline path_hash_mode after sending to %s",
|
||||
channel.name,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Path hash mode restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring path hash mode failed. "
|
||||
"The radio may be using a non-default hop width. "
|
||||
"Consider rebooting the radio."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _extract_expected_ack_code(result: Any) -> str | None:
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
|
||||
@@ -397,6 +397,7 @@ export function App() {
|
||||
handleSendMessage,
|
||||
handleResendChannelMessage,
|
||||
handleSetChannelFloodScopeOverride,
|
||||
handleSetChannelPathHashModeOverride,
|
||||
handleSenderClick,
|
||||
handleTrace,
|
||||
handlePathDiscovery,
|
||||
@@ -527,6 +528,7 @@ export function App() {
|
||||
onDeleteContact: handleDeleteContact,
|
||||
onDeleteChannel: handleDeleteChannel,
|
||||
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride: handleSetChannelPathHashModeOverride,
|
||||
onOpenContactInfo: handleOpenContactInfo,
|
||||
onOpenChannelInfo: handleOpenChannelInfo,
|
||||
onSenderClick: handleSenderClick,
|
||||
|
||||
@@ -210,6 +210,12 @@ export const api = {
|
||||
body: JSON.stringify({ flood_scope_override: floodScopeOverride }),
|
||||
}),
|
||||
|
||||
setChannelPathHashModeOverride: (key: string, pathHashModeOverride: number | null) =>
|
||||
fetchJson<Channel>(`/channels/${key}/path-hash-mode-override`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path_hash_mode_override: pathHashModeOverride }),
|
||||
}),
|
||||
|
||||
// Messages
|
||||
getMessages: (
|
||||
params?: {
|
||||
|
||||
132
frontend/src/components/ChannelPathHashModeOverrideModal.tsx
Normal file
132
frontend/src/components/ChannelPathHashModeOverrideModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Label } from './ui/label';
|
||||
|
||||
const PATH_HASH_MODE_LABELS: Record<number, string> = {
|
||||
0: '1-byte',
|
||||
1: '2-byte',
|
||||
2: '3-byte',
|
||||
};
|
||||
|
||||
interface ChannelPathHashModeOverrideModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
channelName: string;
|
||||
currentOverride: number | null;
|
||||
radioDefault: number;
|
||||
onSetOverride: (value: number | null) => void;
|
||||
}
|
||||
|
||||
export function ChannelPathHashModeOverrideModal({
|
||||
open,
|
||||
onClose,
|
||||
channelName,
|
||||
currentOverride,
|
||||
radioDefault,
|
||||
onSetOverride,
|
||||
}: ChannelPathHashModeOverrideModalProps) {
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(currentOverride);
|
||||
}
|
||||
}, [currentOverride, open]);
|
||||
|
||||
const radioDefaultLabel = PATH_HASH_MODE_LABELS[radioDefault] ?? `${radioDefault}`;
|
||||
|
||||
const options: { value: number | null; label: string; description: string }[] = [
|
||||
{
|
||||
value: null,
|
||||
label: `Radio default (${radioDefaultLabel})`,
|
||||
description: 'Use the radio-wide path hash mode setting',
|
||||
},
|
||||
{
|
||||
value: 0,
|
||||
label: '1-byte hop identifiers',
|
||||
description: 'Shortest paths, least repeater disambiguation',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: '2-byte hop identifiers',
|
||||
description: 'Better repeater disambiguation',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '3-byte hop identifiers',
|
||||
description: 'Best repeater disambiguation, longest paths',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Path Hop Width Override</DialogTitle>
|
||||
<DialogDescription>
|
||||
Override the path hash mode for this channel. Wider hop identifiers improve repeater
|
||||
disambiguation but extend send time and will prevent users on old (<1.14) firmware
|
||||
from receiving the message.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
||||
<div className="font-medium">{channelName}</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
Current override:{' '}
|
||||
{currentOverride != null
|
||||
? (PATH_HASH_MODE_LABELS[currentOverride] ?? `mode ${currentOverride}`)
|
||||
: `none (using radio default: ${radioDefaultLabel})`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Hop width for this channel</Label>
|
||||
<div className="space-y-1.5">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
type="button"
|
||||
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
|
||||
selected === opt.value
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => setSelected(opt.value)}
|
||||
>
|
||||
<div className="font-medium">{opt.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{opt.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:block sm:space-x-0">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onSetOverride(selected);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{selected == null
|
||||
? `Use radio default for ${channelName}`
|
||||
: `Use ${PATH_HASH_MODE_LABELS[selected]} hops for ${channelName}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
|
||||
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
@@ -36,6 +37,7 @@ interface ChatHeaderProps {
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
||||
onDeleteChannel: (key: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
@@ -56,6 +58,7 @@ export function ChatHeader({
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
onDeleteChannel,
|
||||
onDeleteContact,
|
||||
onOpenContactInfo,
|
||||
@@ -64,11 +67,13 @@ export function ChatHeader({
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
|
||||
const [pathHashModeOverrideOpen, setPathHashModeOverrideOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowKey(false);
|
||||
setPathDiscoveryOpen(false);
|
||||
setChannelOverrideOpen(false);
|
||||
setPathHashModeOverrideOpen(false);
|
||||
}, [conversation.id]);
|
||||
|
||||
const activeChannel =
|
||||
@@ -81,6 +86,12 @@ export function ChatHeader({
|
||||
? stripRegionScopePrefix(activeFloodScopeOverride)
|
||||
: null;
|
||||
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
|
||||
const activePathHashModeOverride =
|
||||
conversation.type === 'channel' ? (activeChannel?.path_hash_mode_override ?? null) : null;
|
||||
const showPathHashModeOverride =
|
||||
conversation.type === 'channel' &&
|
||||
onSetChannelPathHashModeOverride &&
|
||||
config?.path_hash_mode_supported;
|
||||
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
|
||||
const activeContact =
|
||||
conversation.type === 'contact'
|
||||
@@ -108,6 +119,11 @@ export function ChatHeader({
|
||||
setChannelOverrideOpen(true);
|
||||
};
|
||||
|
||||
const handleEditPathHashModeOverride = () => {
|
||||
if (conversation.type !== 'channel' || !onSetChannelPathHashModeOverride) return;
|
||||
setPathHashModeOverrideOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenConversationInfo = () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
@@ -323,6 +339,19 @@ export function ChatHeader({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{showPathHashModeOverride && (
|
||||
<button
|
||||
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={handleEditPathHashModeOverride}
|
||||
title="Set path hop width override"
|
||||
aria-label="Set path hop width override"
|
||||
>
|
||||
<ChevronsLeftRight
|
||||
className={`h-4 w-4 ${activePathHashModeOverride != null ? 'text-status-connected' : 'text-muted-foreground'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{(conversation.type === 'channel' || conversation.type === 'contact') && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@@ -379,6 +408,16 @@ export function ChatHeader({
|
||||
onSetOverride={(value) => onSetChannelFloodScopeOverride(conversation.id, value)}
|
||||
/>
|
||||
)}
|
||||
{showPathHashModeOverride && (
|
||||
<ChannelPathHashModeOverrideModal
|
||||
open={pathHashModeOverrideOpen}
|
||||
onClose={() => setPathHashModeOverrideOpen(false)}
|
||||
channelName={conversation.name}
|
||||
currentOverride={activePathHashModeOverride}
|
||||
radioDefault={config?.path_hash_mode ?? 0}
|
||||
onSetOverride={(value) => onSetChannelPathHashModeOverride(conversation.id, value)}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ interface ConversationPaneProps {
|
||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||
onDeleteChannel: (key: string) => Promise<void>;
|
||||
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
||||
onSetChannelPathHashModeOverride?: (
|
||||
channelKey: string,
|
||||
pathHashModeOverride: number | null
|
||||
) => Promise<void>;
|
||||
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||
onOpenChannelInfo: (channelKey: string) => void;
|
||||
onSenderClick: (sender: string) => void;
|
||||
@@ -131,6 +135,7 @@ export function ConversationPane({
|
||||
onDeleteContact,
|
||||
onDeleteChannel,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
onOpenContactInfo,
|
||||
onOpenChannelInfo,
|
||||
onSenderClick,
|
||||
@@ -259,6 +264,7 @@ export function ConversationPane({
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
||||
onDeleteChannel={onDeleteChannel}
|
||||
onDeleteContact={onDeleteContact}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
|
||||
@@ -21,6 +21,10 @@ interface UseConversationActionsResult {
|
||||
channelKey: string,
|
||||
floodScopeOverride: string
|
||||
) => Promise<void>;
|
||||
handleSetChannelPathHashModeOverride: (
|
||||
channelKey: string,
|
||||
pathHashModeOverride: number | null
|
||||
) => Promise<void>;
|
||||
handleSenderClick: (sender: string) => void;
|
||||
handleTrace: () => Promise<void>;
|
||||
handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
@@ -106,6 +110,25 @@ export function useConversationActions({
|
||||
[mergeChannelIntoList]
|
||||
);
|
||||
|
||||
const handleSetChannelPathHashModeOverride = useCallback(
|
||||
async (channelKey: string, pathHashModeOverride: number | null) => {
|
||||
try {
|
||||
const updated = await api.setChannelPathHashModeOverride(channelKey, pathHashModeOverride);
|
||||
mergeChannelIntoList(updated);
|
||||
toast.success(
|
||||
updated.path_hash_mode_override != null
|
||||
? 'Path hop width override saved'
|
||||
: 'Path hop width override cleared'
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error('Failed to update path hop width override', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[mergeChannelIntoList]
|
||||
);
|
||||
|
||||
const handleSenderClick = useCallback(
|
||||
(sender: string) => {
|
||||
messageInputRef.current?.appendText(`@[${sender}] `);
|
||||
@@ -143,6 +166,7 @@ export function useConversationActions({
|
||||
handleSendMessage,
|
||||
handleResendChannelMessage,
|
||||
handleSetChannelFloodScopeOverride,
|
||||
handleSetChannelPathHashModeOverride,
|
||||
handleSenderClick,
|
||||
handleTrace,
|
||||
handlePathDiscovery,
|
||||
|
||||
@@ -201,6 +201,7 @@ export interface Channel {
|
||||
is_hashtag: boolean;
|
||||
on_radio: boolean;
|
||||
flood_scope_override?: string | null;
|
||||
path_hash_mode_override?: number | null;
|
||||
last_read_at: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1249,8 +1249,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 13
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 14
|
||||
assert await get_version(conn) == 52
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1321,8 +1321,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 13
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 14
|
||||
assert await get_version(conn) == 52
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1388,8 +1388,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 7
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 8
|
||||
assert await get_version(conn) == 52
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1441,8 +1441,8 @@ class TestMigration040:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 12
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 13
|
||||
assert await get_version(conn) == 52
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1503,8 +1503,8 @@ class TestMigration041:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 11
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 12
|
||||
assert await get_version(conn) == 52
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1556,8 +1556,8 @@ class TestMigration042:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 10
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 11
|
||||
assert await get_version(conn) == 52
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1696,8 +1696,8 @@ class TestMigration046:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 7
|
||||
assert await get_version(conn) == 52
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1790,8 +1790,8 @@ class TestMigration047:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 51
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 52
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user