mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Add draft reactions + gifs; region resolution
This commit is contained in:
@@ -361,6 +361,7 @@ Distance/validation helpers used by path + map UI.
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `flood_scope`
|
||||
- `known_regions`
|
||||
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||
- `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`
|
||||
- `auto_resend_channel`
|
||||
|
||||
+36
-28
@@ -22,6 +22,7 @@ import { toast } from './components/ui/sonner';
|
||||
import { AppShell } from './components/AppShell';
|
||||
import type { MessageInputHandle } from './components/MessageInput';
|
||||
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { RichPayloadProvider } from './contexts/RichPayloadContext';
|
||||
import { usePush } from './contexts/PushSubscriptionContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
@@ -117,11 +118,13 @@ export function App() {
|
||||
crackerRunning,
|
||||
localLabel,
|
||||
distanceUnit,
|
||||
renderRichPayloads,
|
||||
setSettingsSection,
|
||||
setSidebarOpen,
|
||||
setCrackerRunning,
|
||||
setLocalLabel,
|
||||
setDistanceUnit,
|
||||
setRenderRichPayloads,
|
||||
handleCloseSettingsView,
|
||||
handleToggleSettingsView,
|
||||
handleOpenNewMessage: openNewMessageModal,
|
||||
@@ -794,34 +797,39 @@ export function App() {
|
||||
]);
|
||||
return (
|
||||
<DistanceUnitProvider distanceUnit={distanceUnit} setDistanceUnit={setDistanceUnit}>
|
||||
<AppShell
|
||||
localLabel={localLabel}
|
||||
showNewMessage={showNewMessage}
|
||||
showBulkAddResults={bulkAddResult !== null}
|
||||
showSettings={showSettings}
|
||||
settingsSection={settingsSection}
|
||||
sidebarOpen={sidebarOpen}
|
||||
showCracker={showCracker}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
onSidebarOpenChange={setSidebarOpen}
|
||||
onCrackerRunningChange={setCrackerRunning}
|
||||
onToggleSettingsView={handleToggleSettingsView}
|
||||
onCloseSettingsView={handleCloseSettingsView}
|
||||
onCloseNewMessage={handleCloseNewMessage}
|
||||
onCloseBulkAddResults={handleCloseBulkAddResults}
|
||||
onLocalLabelChange={setLocalLabel}
|
||||
statusProps={statusProps}
|
||||
sidebarProps={sidebarProps}
|
||||
conversationPaneProps={conversationPaneProps}
|
||||
searchProps={searchProps}
|
||||
settingsProps={settingsProps}
|
||||
crackerProps={crackerProps}
|
||||
newMessageModalProps={newMessageModalProps}
|
||||
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
onRepeaterAutoLogin={handleRepeaterAutoLogin}
|
||||
/>
|
||||
<RichPayloadProvider
|
||||
renderRichPayloads={renderRichPayloads}
|
||||
setRenderRichPayloads={setRenderRichPayloads}
|
||||
>
|
||||
<AppShell
|
||||
localLabel={localLabel}
|
||||
showNewMessage={showNewMessage}
|
||||
showBulkAddResults={bulkAddResult !== null}
|
||||
showSettings={showSettings}
|
||||
settingsSection={settingsSection}
|
||||
sidebarOpen={sidebarOpen}
|
||||
showCracker={showCracker}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
onSidebarOpenChange={setSidebarOpen}
|
||||
onCrackerRunningChange={setCrackerRunning}
|
||||
onToggleSettingsView={handleToggleSettingsView}
|
||||
onCloseSettingsView={handleCloseSettingsView}
|
||||
onCloseNewMessage={handleCloseNewMessage}
|
||||
onCloseBulkAddResults={handleCloseBulkAddResults}
|
||||
onLocalLabelChange={setLocalLabel}
|
||||
statusProps={statusProps}
|
||||
sidebarProps={sidebarProps}
|
||||
conversationPaneProps={conversationPaneProps}
|
||||
searchProps={searchProps}
|
||||
settingsProps={settingsProps}
|
||||
crackerProps={crackerProps}
|
||||
newMessageModalProps={newMessageModalProps}
|
||||
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
onRepeaterAutoLogin={handleRepeaterAutoLogin}
|
||||
/>
|
||||
</RichPayloadProvider>
|
||||
</DistanceUnitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
formatTime,
|
||||
parseSenderFromText,
|
||||
} from '../utils/messageParser';
|
||||
import { giphyUrlForId, parseGif, parseReaction } from '../utils/meshcoreOpenPayloads';
|
||||
import { useRichPayloads } from '../contexts/RichPayloadContext';
|
||||
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||
import { getDirectContactRoute } from '../utils/pathUtils';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
@@ -50,6 +52,57 @@ interface MessageListProps {
|
||||
preSorted?: boolean;
|
||||
}
|
||||
|
||||
// Renders a MeshCore Open GIF payload, falling back to the raw text on load error.
|
||||
function GifPayload({ gifId, rawText }: { gifId: string; rawText: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) {
|
||||
return <>{rawText}</>;
|
||||
}
|
||||
const url = giphyUrlForId(gifId);
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block"
|
||||
title="Open GIF on Giphy"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt="GIF"
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className="max-w-[240px] max-h-[240px] rounded-md"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Renders a MeshCore Open reaction generically (emoji + "reacted"); the target
|
||||
// message is not resolved (see issue #291).
|
||||
function ReactionPayload({ emoji }: { emoji: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="text-xl leading-none">{emoji}</span>
|
||||
<span className="text-xs text-muted-foreground italic">reacted</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Recognize a whole-message MeshCore Open payload and render it. Returns null
|
||||
// when the content is not a recognized payload, so the caller renders normally.
|
||||
function renderMeshcoreOpenPayload(content: string): ReactNode | null {
|
||||
const gifId = parseGif(content);
|
||||
if (gifId) {
|
||||
return <GifPayload gifId={gifId} rawText={content} />;
|
||||
}
|
||||
const reaction = parseReaction(content);
|
||||
if (reaction) {
|
||||
return <ReactionPayload emoji={reaction.emoji} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// URL regex for linkifying plain text
|
||||
const URL_PATTERN =
|
||||
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
||||
@@ -241,6 +294,18 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Region scope badge for messages that arrived via a transport-routed (region-scoped) packet.
|
||||
function RegionBadge({ region }: { region: string }) {
|
||||
return (
|
||||
<span
|
||||
className="ml-1.5 align-middle text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
|
||||
title={`Regional scope: ${region}`}
|
||||
>
|
||||
{region}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const RESEND_WINDOW_SECONDS = 30;
|
||||
const CORRUPT_SENDER_LABEL = '<No name -- corrupt packet?>';
|
||||
const ANALYZE_PACKET_NOTICE =
|
||||
@@ -286,6 +351,7 @@ export function MessageList({
|
||||
onJumpToBottom,
|
||||
preSorted = false,
|
||||
}: MessageListProps) {
|
||||
const { renderRichPayloads } = useRichPayloads();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const prevMessagesLengthRef = useRef<number>(0);
|
||||
const isInitialLoadRef = useRef<boolean>(true);
|
||||
@@ -1009,15 +1075,17 @@ export function MessageList({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{msg.region && <RegionBadge region={msg.region} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{content.split('\n').map((line, i, arr) => (
|
||||
<span key={i}>
|
||||
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
|
||||
{i < arr.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
{(renderRichPayloads && renderMeshcoreOpenPayload(content)) ||
|
||||
content.split('\n').map((line, i, arr) => (
|
||||
<span key={i}>
|
||||
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
|
||||
{i < arr.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
{!showAvatar && (
|
||||
<>
|
||||
<span className="text-[0.625rem] text-muted-foreground ml-2">
|
||||
@@ -1037,6 +1105,7 @@ export function MessageList({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{msg.region && <RegionBadge region={msg.region} />}
|
||||
</>
|
||||
)}
|
||||
{msg.outgoing &&
|
||||
|
||||
@@ -672,8 +672,12 @@ export function RawPacketInspectionPanel({
|
||||
{inspection.decoded?.transportCodes ? (
|
||||
<CompactMetaCard
|
||||
label="Scope"
|
||||
primary="Regional"
|
||||
secondary={formatTransportCodes(inspection.decoded.transportCodes)}
|
||||
primary={packet.region ? packet.region : 'Regional'}
|
||||
secondary={
|
||||
packet.region
|
||||
? formatTransportCodes(inspection.decoded.transportCodes)
|
||||
: `${formatTransportCodes(inspection.decoded.transportCodes)} · unknown region`
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{(() => {
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
setSavedDistanceUnit,
|
||||
} from '../../utils/distanceUnits';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import { useRichPayloads } from '../../contexts/RichPayloadContext';
|
||||
import { setSavedRenderRichPayloads } from '../../utils/richPayloadPreference';
|
||||
import {
|
||||
DEFAULT_FONT_SCALE,
|
||||
FONT_SCALE_SLIDER_STEP,
|
||||
@@ -230,6 +232,7 @@ export function SettingsLocalSection({
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
|
||||
const { renderRichPayloads, setRenderRichPayloads } = useRichPayloads();
|
||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||
getReopenLastConversationEnabled
|
||||
);
|
||||
@@ -450,6 +453,33 @@ export function SettingsLocalSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||
<Checkbox
|
||||
id="render-rich-payloads"
|
||||
checked={renderRichPayloads}
|
||||
onCheckedChange={(checked) => {
|
||||
const v = checked === true;
|
||||
setRenderRichPayloads(v);
|
||||
setSavedRenderRichPayloads(v);
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="render-rich-payloads">
|
||||
Render MeshCore Open GIFs & Reactions
|
||||
</Label>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
MeshCore Open clients send GIFs and emoji reactions as encoded text (e.g.{' '}
|
||||
<code className="text-[0.75rem]">g:abc123</code> or{' '}
|
||||
<code className="text-[0.75rem]">r:1a2b:05</code>). When enabled, these render as
|
||||
the GIF image or reaction emoji instead of the raw text. Reactions show generically
|
||||
(the emoji is not tied to a specific message). GIFs load from media.giphy.com, which
|
||||
reaches outside your local network and exposes your IP to Giphy — so this is off by
|
||||
default.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/60 p-3 space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
|
||||
@@ -200,6 +200,7 @@ export function SettingsRadioSection({
|
||||
// Flood & advert control state
|
||||
const [advertIntervalHours, setAdvertIntervalHours] = useState('0');
|
||||
const [floodScope, setFloodScope] = useState('');
|
||||
const [knownRegions, setKnownRegions] = useState('');
|
||||
const [maxRadioContacts, setMaxRadioContacts] = useState('');
|
||||
const [floodBusy, setFloodBusy] = useState(false);
|
||||
const [floodError, setFloodError] = useState<string | null>(null);
|
||||
@@ -229,6 +230,7 @@ export function SettingsRadioSection({
|
||||
useEffect(() => {
|
||||
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
|
||||
setFloodScope(stripRegionScopePrefix(appSettings.flood_scope));
|
||||
setKnownRegions((appSettings.known_regions ?? []).join('\n'));
|
||||
setMaxRadioContacts(String(appSettings.max_radio_contacts));
|
||||
}, [appSettings]);
|
||||
|
||||
@@ -414,6 +416,14 @@ export function SettingsRadioSection({
|
||||
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
|
||||
update.flood_scope = floodScope;
|
||||
}
|
||||
// Known regions: one per line (commas also accepted), trimmed, blanks dropped.
|
||||
const parsedRegions = knownRegions
|
||||
.split(/[\n,]/)
|
||||
.map((r) => r.trim())
|
||||
.filter((r) => r.length > 0);
|
||||
if (JSON.stringify(parsedRegions) !== JSON.stringify(appSettings.known_regions ?? [])) {
|
||||
update.known_regions = parsedRegions;
|
||||
}
|
||||
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
|
||||
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings.max_radio_contacts) {
|
||||
update.max_radio_contacts = newMaxRadioContacts;
|
||||
@@ -1157,6 +1167,25 @@ export function SettingsRadioSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="known-regions">Known Regions (for decoding)</Label>
|
||||
<textarea
|
||||
id="known-regions"
|
||||
value={knownRegions}
|
||||
onChange={(e) => setKnownRegions(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={'nl-gr\nde-by\nMyRegion'}
|
||||
spellCheck={false}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
One region name per line. Incoming region-scoped (TransportFlood/TransportDirect) packets
|
||||
are matched against this list so messages and the packet inspector show a readable region
|
||||
label instead of a raw transport code. The list is seeded from your channels' regions and
|
||||
can be edited freely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
|
||||
<Input
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
interface RichPayloadContextValue {
|
||||
renderRichPayloads: boolean;
|
||||
setRenderRichPayloads: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const RichPayloadContext = createContext<RichPayloadContextValue>({
|
||||
renderRichPayloads: false,
|
||||
setRenderRichPayloads: noop,
|
||||
});
|
||||
|
||||
export function RichPayloadProvider({
|
||||
renderRichPayloads,
|
||||
setRenderRichPayloads,
|
||||
children,
|
||||
}: RichPayloadContextValue & { children: ReactNode }) {
|
||||
return (
|
||||
<RichPayloadContext.Provider value={{ renderRichPayloads, setRenderRichPayloads }}>
|
||||
{children}
|
||||
</RichPayloadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRichPayloads() {
|
||||
return useContext(RichPayloadContext);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { startTransition, useCallback, useEffect, useRef, useState } from 'react
|
||||
|
||||
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
|
||||
import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits';
|
||||
import { getSavedRenderRichPayloads } from '../utils/richPayloadPreference';
|
||||
import type { SettingsSection } from '../components/settings/settingsConstants';
|
||||
import { parseHashSettingsSection, updateSettingsHash, pushSettingsHash } from '../utils/urlHash';
|
||||
|
||||
@@ -14,11 +15,13 @@ interface UseAppShellResult {
|
||||
crackerRunning: boolean;
|
||||
localLabel: LocalLabel;
|
||||
distanceUnit: DistanceUnit;
|
||||
renderRichPayloads: boolean;
|
||||
setSettingsSection: (section: SettingsSection) => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setCrackerRunning: (running: boolean) => void;
|
||||
setLocalLabel: (label: LocalLabel) => void;
|
||||
setDistanceUnit: (unit: DistanceUnit) => void;
|
||||
setRenderRichPayloads: (enabled: boolean) => void;
|
||||
handleCloseSettingsView: () => void;
|
||||
handleToggleSettingsView: () => void;
|
||||
handleOpenNewMessage: () => void;
|
||||
@@ -38,6 +41,7 @@ export function useAppShell(): UseAppShellResult {
|
||||
const [crackerRunning, setCrackerRunning] = useState(false);
|
||||
const [localLabel, setLocalLabel] = useState(getLocalLabel);
|
||||
const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit);
|
||||
const [renderRichPayloads, setRenderRichPayloads] = useState(getSavedRenderRichPayloads);
|
||||
const previousHashRef = useRef('');
|
||||
const isOpeningSettingsRef = useRef(false);
|
||||
const pushedSettingsEntryRef = useRef(false);
|
||||
@@ -127,11 +131,13 @@ export function useAppShell(): UseAppShellResult {
|
||||
crackerRunning,
|
||||
localLabel,
|
||||
distanceUnit,
|
||||
renderRichPayloads,
|
||||
setSettingsSection,
|
||||
setSidebarOpen,
|
||||
setCrackerRunning,
|
||||
setLocalLabel,
|
||||
setDistanceUnit,
|
||||
setRenderRichPayloads,
|
||||
handleCloseSettingsView,
|
||||
handleToggleSettingsView,
|
||||
handleOpenNewMessage,
|
||||
|
||||
@@ -222,6 +222,7 @@ const baseSettings = {
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
known_regions: [],
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
};
|
||||
|
||||
@@ -105,6 +105,7 @@ beforeEach(() => {
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
known_regions: [],
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
@@ -1147,6 +1148,7 @@ describe('SettingsFanoutSection', () => {
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
known_regions: [],
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Tests for MeshCore Open rich-chat payload parsing (GIFs and reactions).
|
||||
*
|
||||
* Formats are ported from meshcore-open; see meshcoreOpenPayloads.ts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
REACTION_EMOJIS,
|
||||
giphyUrlForId,
|
||||
parseGif,
|
||||
parseReaction,
|
||||
} from '../utils/meshcoreOpenPayloads';
|
||||
|
||||
describe('parseGif', () => {
|
||||
it('parses a g:<id> payload', () => {
|
||||
expect(parseGif('g:abc123')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('accepts ids with underscores and dashes', () => {
|
||||
expect(parseGif('g:aB3_-xY')).toBe('aB3_-xY');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(parseGif(' g:abc123 ')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('returns null for non-gif text', () => {
|
||||
expect(parseGif('hello world')).toBeNull();
|
||||
expect(parseGif('g:')).toBeNull();
|
||||
expect(parseGif('g:abc 123')).toBeNull();
|
||||
expect(parseGif('prefix g:abc')).toBeNull();
|
||||
expect(parseGif('g:abc!')).toBeNull();
|
||||
});
|
||||
|
||||
it('builds the Giphy media URL', () => {
|
||||
expect(giphyUrlForId('abc123')).toBe('https://media.giphy.com/media/abc123/giphy.gif');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseReaction', () => {
|
||||
it('decodes the first emoji (index 00)', () => {
|
||||
const result = parseReaction('r:1a2b:00');
|
||||
expect(result).toEqual({ emoji: REACTION_EMOJIS[0], targetHash: '1a2b' });
|
||||
expect(result?.emoji).toBe('👍');
|
||||
});
|
||||
|
||||
it('decodes a non-zero index', () => {
|
||||
// index 0x06 -> first smiley (after the 6 quick emojis)
|
||||
const result = parseReaction('r:ffff:06');
|
||||
expect(result?.emoji).toBe(REACTION_EMOJIS[6]);
|
||||
expect(result?.targetHash).toBe('ffff');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(parseReaction(' r:1a2b:00 ')?.emoji).toBe('👍');
|
||||
});
|
||||
|
||||
it('returns null for an out-of-range index', () => {
|
||||
// 0xff (255) is beyond the emoji list length
|
||||
expect(parseReaction('r:1a2b:ff')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed reactions', () => {
|
||||
expect(parseReaction('r:1a2b')).toBeNull();
|
||||
expect(parseReaction('r:1a2:00')).toBeNull(); // hash too short
|
||||
expect(parseReaction('r:1A2B:00')).toBeNull(); // uppercase hex not accepted
|
||||
expect(parseReaction('r:1a2b:0')).toBeNull(); // index too short
|
||||
expect(parseReaction('hello')).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes a stable, deduplication-free emoji index range', () => {
|
||||
// 6 quick + 64 smileys + 33 gestures + 32 hearts + 49 objects
|
||||
expect(REACTION_EMOJIS.length).toBe(184);
|
||||
// every defined index decodes to a string
|
||||
for (let i = 0; i < REACTION_EMOJIS.length; i++) {
|
||||
const hex = i.toString(16).padStart(2, '0');
|
||||
expect(parseReaction(`r:0000:${hex}`)?.emoji).toBe(REACTION_EMOJIS[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,31 @@ describe('MessageList channel sender rendering', () => {
|
||||
expect(screen.getByTestId('corrupt-avatar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a region badge for region-scoped channel messages', () => {
|
||||
render(
|
||||
<MessageList
|
||||
messages={[createMessage({ sender_name: 'Alice', region: 'nl-gr' })]}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('nl-gr')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Regional scope: nl-gr')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render a region badge for unscoped messages', () => {
|
||||
render(
|
||||
<MessageList
|
||||
messages={[createMessage({ sender_name: 'Alice', region: null })]}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('nl-gr')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefers stored sender_name for channel messages even when text is not sender-prefixed', () => {
|
||||
render(
|
||||
<MessageList
|
||||
|
||||
@@ -91,11 +91,26 @@ describe('RawPacketDetailModal', () => {
|
||||
expect(pathRun.className).toBe(idleClassName);
|
||||
});
|
||||
|
||||
it('shows scope card with transport codes for scoped packets', () => {
|
||||
it('shows scope card with transport codes for scoped packets without a resolved region', () => {
|
||||
render(<RawPacketDetailModal packet={SCOPED_PACKET} channels={[]} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('Scope')).toBeInTheDocument();
|
||||
expect(screen.getByText('Regional')).toBeInTheDocument();
|
||||
expect(screen.getByText('0x1234, 0x5678 · unknown region')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the resolved region name in the scope card when the backend matched one', () => {
|
||||
render(
|
||||
<RawPacketDetailModal
|
||||
packet={{ ...SCOPED_PACKET, region: 'nl-gr', transport_code: 0x1234 }}
|
||||
channels={[]}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Scope')).toBeInTheDocument();
|
||||
expect(screen.getByText('nl-gr')).toBeInTheDocument();
|
||||
// Raw codes remain visible as the secondary detail.
|
||||
expect(screen.getByText('0x1234, 0x5678')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
RENDER_RICH_PAYLOADS_KEY,
|
||||
getSavedRenderRichPayloads,
|
||||
setSavedRenderRichPayloads,
|
||||
} from '../utils/richPayloadPreference';
|
||||
|
||||
describe('richPayloadPreference utilities', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('defaults to off when unset', () => {
|
||||
expect(getSavedRenderRichPayloads()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when enabled', () => {
|
||||
localStorage.setItem(RENDER_RICH_PAYLOADS_KEY, 'true');
|
||||
expect(getSavedRenderRichPayloads()).toBe(true);
|
||||
});
|
||||
|
||||
it('treats any non-"true" value as off', () => {
|
||||
localStorage.setItem(RENDER_RICH_PAYLOADS_KEY, 'yes');
|
||||
expect(getSavedRenderRichPayloads()).toBe(false);
|
||||
});
|
||||
|
||||
it('persists when enabled and clears the key when disabled', () => {
|
||||
setSavedRenderRichPayloads(true);
|
||||
expect(localStorage.getItem(RENDER_RICH_PAYLOADS_KEY)).toBe('true');
|
||||
|
||||
setSavedRenderRichPayloads(false);
|
||||
expect(localStorage.getItem(RENDER_RICH_PAYLOADS_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,7 @@ const baseSettings: AppSettings = {
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
known_regions: [],
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
|
||||
@@ -309,6 +309,10 @@ export interface Message {
|
||||
sender_name: string | null;
|
||||
channel_name?: string | null;
|
||||
packet_id?: number | null;
|
||||
/** Region scope transport code (uint16) when this arrived via a transport-routed packet. */
|
||||
transport_code?: number | null;
|
||||
/** Resolved region name for the transport code, if it matched a known region. */
|
||||
region?: string | null;
|
||||
}
|
||||
|
||||
export interface MessagesAroundResponse {
|
||||
@@ -352,6 +356,10 @@ export interface RawPacket {
|
||||
sender_timestamp: number | null;
|
||||
message: string | null;
|
||||
} | null;
|
||||
/** Region scope transport code (uint16) for TransportFlood/TransportDirect packets. */
|
||||
transport_code?: number | null;
|
||||
/** Resolved region name for the transport code, if it matched a known region. */
|
||||
region?: string | null;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
@@ -361,6 +369,7 @@ export interface AppSettings {
|
||||
advert_interval: number;
|
||||
last_advert_time: number;
|
||||
flood_scope: string;
|
||||
known_regions: string[];
|
||||
blocked_keys: string[];
|
||||
blocked_names: string[];
|
||||
discovery_blocked_types: number[];
|
||||
@@ -377,6 +386,7 @@ export interface AppSettingsUpdate {
|
||||
advert_interval?: number;
|
||||
auto_resend_channel?: boolean;
|
||||
flood_scope?: string;
|
||||
known_regions?: string[];
|
||||
blocked_keys?: string[];
|
||||
blocked_names?: string[];
|
||||
discovery_blocked_types?: number[];
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Parsing for rich-chat payloads sent by MeshCore Open clients as ordinary
|
||||
* plaintext mesh messages.
|
||||
*
|
||||
* MeshCore Open encodes some rich features into the message body with a short
|
||||
* prefix. RemoteTerm recognizes two of them for display:
|
||||
*
|
||||
* g:<gifId> Giphy GIF -> https://media.giphy.com/media/<id>/giphy.gif
|
||||
* r:<hash>:<index> Emoji reaction -> <index> picks an emoji from a fixed list
|
||||
*
|
||||
* Formats and the emoji table are ported verbatim from meshcore-open:
|
||||
* lib/helpers/gif_helper.dart
|
||||
* lib/helpers/reaction_helper.dart
|
||||
* lib/widgets/emoji_picker.dart
|
||||
* (github.com/zjs81/meshcore-open, dev branch).
|
||||
*
|
||||
* Reaction support here is intentionally "generic display only": we decode the
|
||||
* emoji from <index> and show it, but we do NOT resolve <hash> back to the
|
||||
* target message (that requires porting Dart's String.hashCode). See issue #291.
|
||||
*/
|
||||
|
||||
// --- Emoji table (order must match meshcore-open exactly for index compat) ---
|
||||
|
||||
const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
|
||||
|
||||
// prettier-ignore
|
||||
const SMILEYS = [
|
||||
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂',
|
||||
'🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋',
|
||||
'😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩',
|
||||
'🥳', '😏', '😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖',
|
||||
'😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '🤬', '🤯',
|
||||
'😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔',
|
||||
'🤭', '🤫', '🤥', '😶',
|
||||
];
|
||||
|
||||
// prettier-ignore
|
||||
const GESTURES = [
|
||||
'👍', '👎', '👊', '✊', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘',
|
||||
'👌', '🤌', '🤏', '👈', '👉', '👆', '👇', '☝️', '👋', '🤚',
|
||||
'🖐️', '✋', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️',
|
||||
'💅', '🤳', '💪',
|
||||
];
|
||||
|
||||
// prettier-ignore
|
||||
const HEARTS = [
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
|
||||
'❤️🔥', '❤️🩹', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟',
|
||||
'💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️🗨️', '🗨️',
|
||||
'🗯️', '💭',
|
||||
];
|
||||
|
||||
// prettier-ignore
|
||||
const OBJECTS = [
|
||||
'🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈',
|
||||
'🥉', '⚽', '⚾', '🥎', '🏀', '🏐', '🏈', '🏉', '🎾', '🥏',
|
||||
'🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅',
|
||||
'⛳', '🔥', '⭐', '🌟', '✨', '⚡', '💡', '🔦', '🏮', '🪔',
|
||||
'📱', '💻', '⌚', '📷', '📺', '📻', '🎵', '🎶', '🚀',
|
||||
];
|
||||
|
||||
/** Combined reaction emoji list, in the fixed index order used on the wire. */
|
||||
export const REACTION_EMOJIS: readonly string[] = [
|
||||
...QUICK_EMOJIS,
|
||||
...SMILEYS,
|
||||
...GESTURES,
|
||||
...HEARTS,
|
||||
...OBJECTS,
|
||||
];
|
||||
|
||||
// --- GIF (g:<gifId>) ---
|
||||
|
||||
const GIF_PATTERN = /^g:([A-Za-z0-9_-]+)$/;
|
||||
|
||||
/**
|
||||
* Parse a MeshCore Open GIF payload. Returns the Giphy GIF id, or null if the
|
||||
* (trimmed) text is not a `g:<id>` payload.
|
||||
*/
|
||||
export function parseGif(text: string): string | null {
|
||||
const match = GIF_PATTERN.exec(text.trim());
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** Build the Giphy media URL for a GIF id. */
|
||||
export function giphyUrlForId(gifId: string): string {
|
||||
return `https://media.giphy.com/media/${gifId}/giphy.gif`;
|
||||
}
|
||||
|
||||
// --- Reaction (r:<hash>:<index>) ---
|
||||
|
||||
const REACTION_PATTERN = /^r:([0-9a-f]{4}):([0-9a-f]{2})$/;
|
||||
|
||||
export interface ParsedReaction {
|
||||
/** The decoded reaction emoji. */
|
||||
emoji: string;
|
||||
/** 4-hex hash identifying the target message (not resolved here). */
|
||||
targetHash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a MeshCore Open reaction payload. Returns the decoded emoji and the
|
||||
* (unresolved) target-message hash, or null if the (trimmed) text is not a
|
||||
* valid `r:<hash>:<index>` payload or the index is out of range.
|
||||
*/
|
||||
export function parseReaction(text: string): ParsedReaction | null {
|
||||
const match = REACTION_PATTERN.exec(text.trim());
|
||||
if (!match) return null;
|
||||
const index = parseInt(match[2], 16);
|
||||
if (!Number.isInteger(index) || index < 0 || index >= REACTION_EMOJIS.length) {
|
||||
return null;
|
||||
}
|
||||
return { emoji: REACTION_EMOJIS[index], targetHash: match[1] };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Browser-local preference for rendering MeshCore Open rich-chat payloads
|
||||
// (Giphy GIFs and emoji reactions) instead of their raw encoded text. This is
|
||||
// a pure display tweak, stored per-browser in localStorage. GIF rendering
|
||||
// fetches images from media.giphy.com, so it is off by default.
|
||||
|
||||
export const RENDER_RICH_PAYLOADS_KEY = 'remoteterm-render-rich-payloads';
|
||||
|
||||
export function getSavedRenderRichPayloads(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(RENDER_RICH_PAYLOADS_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSavedRenderRichPayloads(enabled: boolean): void {
|
||||
try {
|
||||
if (enabled) {
|
||||
localStorage.setItem(RENDER_RICH_PAYLOADS_KEY, 'true');
|
||||
} else {
|
||||
localStorage.removeItem(RENDER_RICH_PAYLOADS_KEY);
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user