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
+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