mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-01 06:22:54 +02:00
Add draft reactions + gifs; region resolution
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user