Add draft reactions + gifs; region resolution

This commit is contained in:
Jack Kingsman
2026-06-20 21:12:47 -07:00
parent cb4d4ca584
commit 06556a853d
35 changed files with 1111 additions and 44 deletions
+1
View File
@@ -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
View File
@@ -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>
);
}
+75 -6
View File
@@ -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 &amp; 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);
}
+6
View File
@@ -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,
+1
View File
@@ -222,6 +222,7 @@ const baseSettings = {
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
known_regions: [],
blocked_keys: [],
blocked_names: [],
};
+2
View File
@@ -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]);
}
});
});
+25
View File
@@ -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();
});
});
+1
View File
@@ -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: [],
+10
View File
@@ -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[];
+113
View File
@@ -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
}
}