[MIRROR] PDA Embedded Images (#8970)

Co-authored-by: Heroman3003 <31296024+Heroman3003@users.noreply.github.com>
Co-authored-by: Kashargul <144968721+Kashargul@users.noreply.github.com>
This commit is contained in:
CHOMPStation2
2024-09-16 01:34:40 -07:00
committed by GitHub
parent f47c6ac831
commit 3103d2fd4d
23 changed files with 519 additions and 333 deletions

View File

@@ -69,3 +69,9 @@
savefile_key = "AUTO_AFK"
default_value = TRUE
savefile_identifier = PREFERENCE_PLAYER
/datum/preference/toggle/messenger_embeds
category = PREFERENCE_CATEGORY_GAME_PREFERENCES
savefile_key = "MessengerEmbeds"
default_value = TRUE
savefile_identifier = PREFERENCE_PLAYER

View File

@@ -22,6 +22,7 @@
data["silent"] = notify_silent // does the pda make noise when it receives a message?
data["toff"] = toff // is the messenger function turned off?
data["active_conversation"] = active_conversation // Which conversation are we following right now?
data["enable_message_embeds"] = user?.client?.prefs?.read_preference(/datum/preference/toggle/messenger_embeds)
has_back = active_conversation
if(active_conversation)

View File

@@ -11,5 +11,6 @@ import 'core-js/es';
import 'core-js/web/immediate';
import 'core-js/web/queue-microtask';
import 'core-js/web/timers';
import 'core-js/full/url';
import 'regenerator-runtime/runtime';
import 'unfetch/polyfill';

View File

@@ -1,11 +1,10 @@
import { BooleanLike } from 'common/react';
import { useState } from 'react';
import { useBackend } from '../backend';
import { Box, Button, Flex, Icon, LabeledList, Section } from '../components';
import { Window } from '../layouts';
import { useBackend } from 'tgui/backend';
import { Box, Button, Flex, Icon, LabeledList, Section } from 'tgui/components';
import { Window } from 'tgui/layouts';
/* This is all basically stolen from routes.js. */
import { routingError } from '../routes';
import { routingError } from 'tgui/routes';
type Data = {
owner: string;
@@ -25,7 +24,7 @@ type Data = {
};
};
const requirePdaInterface = require.context('./pda', false, /\.tsx$/);
const requirePdaInterface = require.context('./pda_screens', false, /\.tsx$/);
function getPdaApp(name: string) {
let appModule: __WebpackModuleApi.RequireContext;

View File

@@ -1,8 +1,7 @@
import { filter } from 'common/collections';
import { decodeHtmlEntities } from 'common/string';
import { useBackend } from '../../backend';
import { Box, LabeledList } from '../../components';
import { useBackend } from 'tgui/backend';
import { Box, LabeledList } from 'tgui/components';
type Data = {
aircontents: aircontent[];

View File

@@ -1,5 +1,5 @@
import { useBackend } from '../../backend';
import { Box, LabeledList, Section } from '../../components';
import { useBackend } from 'tgui/backend';
import { Box, LabeledList, Section } from 'tgui/components';
type Data = {
janitor: {

View File

@@ -1,7 +1,6 @@
import { BooleanLike } from 'common/react';
import { useBackend } from '../../backend';
import { Box, Button, LabeledList, Section } from '../../components';
import { useBackend } from 'tgui/backend';
import { Box, Button, LabeledList, Section } from 'tgui/components';
type Data = {
owner: string;

View File

@@ -1,6 +1,7 @@
import { useBackend } from '../../backend';
import { Box } from '../../components';
import { CrewManifestContent } from '../CrewManifest';
import { useBackend } from 'tgui/backend';
import { Box } from 'tgui/components';
import { CrewManifestContent } from '../../CrewManifest';
export const pda_manifest = (props) => {
const { act, data } = useBackend();

View File

@@ -1,6 +1,7 @@
import { useBackend } from '../../backend';
import { Box, Button, LabeledList, Section } from '../../components';
import { GeneralRecord, MedicalRecord, RecordList } from './pda_types';
import { useBackend } from 'tgui/backend';
import { Box, Button, LabeledList, Section } from 'tgui/components';
import { GeneralRecord, MedicalRecord, RecordList } from '../pda_types';
type Data = {
records: {

View File

@@ -0,0 +1,463 @@
import { filter } from 'common/collections';
import { BooleanLike } from 'common/react';
import { decodeHtmlEntities } from 'common/string';
import { ReactNode, useEffect, useRef, useState } from 'react';
import { useBackend } from 'tgui/backend';
import { Box, Button, Image, LabeledList, Section } from 'tgui/components';
import { fetchRetry } from '../../../http';
type Data = {
active_conversation: string;
convo_name: string;
convo_job: string;
messages: message[];
toff: BooleanLike;
silent: BooleanLike;
convopdas: pda[];
pdas: pda[];
charges: number;
plugins: { name: string; icon: string; ref: string }[];
enable_message_embeds: BooleanLike;
};
type pda = {
Name: string;
Reference: string;
Detonate: string;
inconvo: string;
};
type message = {
sent: BooleanLike;
owner: string;
job: string;
message: string;
target: string;
};
// Really cursed old API that was deprecated before IE8 but still works in IE11 because lol lmao
type IeWindow = Window &
typeof globalThis & {
clipboardData: {
setData: (type: 'Text', text: string) => {};
};
};
const CopyToClipboardButton = (props: { messages: message[] }) => {
const [showCompletion, setShowCompletion] = useState(false);
useEffect(() => {
if (showCompletion) {
let timeout = setTimeout(() => {
setShowCompletion(false);
}, 1000);
return () => clearTimeout(timeout);
}
}, [showCompletion]);
const { messages } = props;
return (
<Button
icon="clipboard"
onClick={() => {
copyToClipboard(messages);
setShowCompletion(true);
}}
selected={showCompletion}
>
{showCompletion ? 'Copied!' : 'Copy to Clipboard'}
</Button>
);
};
const copyToClipboard = (messages: message[]) => {
let string = '';
for (let message of messages) {
if (message.sent) {
string += `You: ${message.message}\n`;
} else {
string += `Them: ${message.message}\n`;
}
}
let ie_window = window as IeWindow;
ie_window.clipboardData.setData('Text', string);
};
export const pda_messenger = (props) => {
const { act, data } = useBackend<Data>();
const { active_conversation } = data;
if (active_conversation) {
return <ActiveConversation />;
}
return <MessengerList />;
};
const MessengerList = (props) => {
const { act, data } = useBackend<Data>();
const { convopdas, pdas, charges, silent, toff } = data;
return (
<Box>
<LabeledList>
<LabeledList.Item label="Messenger Functions">
<Button
selected={!silent}
icon={silent ? 'volume-mute' : 'volume-up'}
onClick={() => act('Toggle Ringer')}
>
Ringer: {silent ? 'Off' : 'On'}
</Button>
<Button
color={toff ? 'bad' : 'green'}
icon="power-off"
onClick={() => act('Toggle Messenger')}
>
Messenger: {toff ? 'Off' : 'On'}
</Button>
<Button icon="bell" onClick={() => act('Ringtone')}>
Set Ringtone
</Button>
<Button
icon="trash"
color="bad"
onClick={() => act('Clear', { option: 'All' })}
>
Delete All Conversations
</Button>
</LabeledList.Item>
</LabeledList>
{(!toff && (
<Box>
{!!charges && <Box>{charges} charges left.</Box>}
{(!convopdas.length && !pdas.length && (
<Box>No other PDAs located.</Box>
)) || (
<Box>
<PDAList
title="Current Conversations"
pdas={convopdas}
msgAct="Select Conversation"
/>
<PDAList title="Other PDAs" pdas={pdas} msgAct="Message" />
</Box>
)}
</Box>
)) || (
<Box color="bad" mt={2}>
Messenger Offline.
</Box>
)}
</Box>
);
};
const PDAList = (props) => {
const { act, data } = useBackend<Data>();
const { pdas, title, msgAct } = props;
const { charges, plugins } = data;
if (!pdas || !pdas.length) {
return <Section title={title}>No PDAs found.</Section>;
}
return (
<Section title={title}>
{pdas.map((pda) => (
<Box key={pda.Reference}>
<Button
icon="arrow-circle-down"
onClick={() => act(msgAct, { target: pda.Reference })}
>
{pda.Name}
</Button>
{!!charges &&
plugins.map((plugin) => (
<Button
key={plugin.ref}
icon={plugin.icon}
onClick={() =>
act('Messenger Plugin', {
plugin: plugin.ref,
target: pda.Reference,
})
}
>
{plugin.name}
</Button>
))}
</Box>
))}
</Section>
);
};
const ActiveConversation = (props) => {
const { act, data } = useBackend<Data>();
const { convo_name, convo_job, messages, active_conversation } = data;
const [asciiMode, setAsciiMode] = useState(false);
return (
<Section
title={`Conversation with ${convo_name} (${convo_job})`}
buttons={
<>
<Button
icon="eye"
selected={asciiMode}
tooltip="ASCII Mode"
tooltipPosition="bottom-end"
onClick={() => setAsciiMode(!asciiMode)}
/>
<Button
icon="reply"
tooltip="Reply"
tooltipPosition="bottom-end"
onClick={() => act('Message', { target: active_conversation })}
/>
<Button.Confirm
icon="trash"
color="bad"
tooltip="Delete Conversation"
tooltipPosition="bottom-end"
onClick={() => act('Clear', { option: 'Convo' })}
/>
</>
}
>
<ScrollOnMount>
{asciiMode ? (
<ActiveConversationASCII
messages={messages}
active_conversation={active_conversation}
/>
) : (
<ActiveConversationTinder
messages={messages}
active_conversation={active_conversation}
/>
)}
</ScrollOnMount>
<Button
mt={1}
icon="comment"
onClick={() => act('Message', { target: active_conversation })}
>
Reply
</Button>
<CopyToClipboardButton
messages={messages.filter((i) => i.target === active_conversation)}
/>
</Section>
);
};
/**
* Scrolls to the bottom of section on mount.
*/
const ScrollOnMount = (props: { children: ReactNode }) => {
const { children } = props;
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
ref.current?.scrollIntoView();
}, []);
return (
<Section fill height="63vh" scrollable>
{children}
<div ref={ref} />
</Section>
);
};
const ActiveConversationASCII = (props: {
messages: message[];
active_conversation: string;
}) => {
const { messages, active_conversation } = props;
return (
<Box>
{filter(messages, (im: message) => im.target === active_conversation).map(
(im, i) => (
<Box
key={i}
className={
im.sent ? 'ClassicMessage_Sent' : 'ClassicMessage_Received'
}
>
{im.sent ? 'You:' : 'Them:'} {decodeHtmlEntities(im.message)}
</Box>
),
)}
</Box>
);
};
const findClassMessage = (im, lastIndex, filterArray) => {
if (lastIndex < 0 || lastIndex > filterArray.length) {
return im.sent
? 'TinderMessage_First_Sent'
: 'TinderMessage_First_Received';
}
let lastSent = filterArray[lastIndex].sent;
if (im.sent && lastSent) {
return 'TinderMessage_Subsequent_Sent';
} else if (!im.sent && !lastSent) {
return 'TinderMessage_Subsequent_Received';
}
return im.sent ? 'TinderMessage_First_Sent' : 'TinderMessage_First_Received';
};
const ActiveConversationTinder = (props: {
messages: message[];
active_conversation: string;
}) => {
const { messages, active_conversation } = props;
return (
<Box>
{messages
.filter((im: message) => im.target === active_conversation)
.map((im, i, filterArr) => (
<TinderMessage
key={i}
im={im}
className={findClassMessage(im, i - 1, filterArr)}
/>
))}
</Box>
);
};
const TinderMessage = (props: { im: message; className: string }) => {
const { data } = useBackend<Data>();
const { enable_message_embeds } = data;
const { im, className } = props;
return (
<>
<Box textAlign={im.sent ? 'right' : 'left'} mb={1}>
<Box maxWidth="75%" className={className} inline>
{decodeHtmlEntities(im.message)}
</Box>
</Box>
{!!enable_message_embeds && (
<TinderMessageEmbedAttempt im={im} className={className} />
)}
</>
);
};
const ALLOWED_HOSTNAMES = ['cdn.discordapp.com', 'i.imgur.com', 'imgur.com'];
const TinderMessageEmbedAttempt = (props: {
im: message;
className: string;
}) => {
const { im, className } = props;
const [elem, setElem] = useState<ReactNode>(null);
useEffect(() => {
let link = decodeHtmlEntities(im.message.trim());
// Early easy check
if (!link.startsWith('https://')) {
return;
}
// We assume the entire message is a URL, so any spaces disqualify it
if (link.includes(' ')) {
return;
}
// Try to parse it as a URL.
let url: URL;
try {
url = new URL(link);
} catch (err) {
return;
}
// Okay, we're pretty damn confident this is a URL now: Check for allowed domains.
if (!ALLOWED_HOSTNAMES.includes(url.hostname)) {
return;
}
async function resolveUrlToImg(url: URL): Promise<string | null> {
const headers = {
Accept: 'image/jpeg, image/png, image/gif',
'User-Agent': 'SS13-Virgo-ImageEmbeds/1.0',
};
const response = await fetchRetry(url.toString(), { headers });
if (!response.ok) {
return null;
}
let type = response.headers.get('Content-Type');
// If we just fetched an image, use it!
if (
type === 'image/jpeg' ||
type === 'image/png' ||
type === 'image/gif'
) {
return url.toString();
}
return null;
}
resolveUrlToImg(url).then((val) => {
if (val) {
setElem(<TinderMessageEmbed im={im} className={className} img={val} />);
}
});
}, []);
return elem;
};
const TinderMessageEmbed = (props: {
im: message;
className: string;
img: string;
}) => {
const [show, setShow] = useState(false);
const { im, className, img } = props;
return (
<Box textAlign={im.sent ? 'right' : 'left'} mb={1}>
<Box maxWidth="75%" className={className} inline>
<Box fontSize={0.9}>Embed</Box>
{show ? (
<Image fixBlur={false} src={img} maxWidth={30} maxHeight={30} />
) : (
<Button
width={30}
height={20}
onClick={() => setShow(true)}
color="black"
textAlign="center"
verticalAlignContent="middle"
fontSize={3}
>
Click to
<br />
Show
</Button>
)}
</Box>
</Box>
);
};

View File

@@ -1,8 +1,7 @@
import { BooleanLike } from 'common/react';
import { decodeHtmlEntities } from 'common/string';
import { useBackend } from '../../backend';
import { Box, Button, Image, Section } from '../../components';
import { useBackend } from 'tgui/backend';
import { Box, Button, Image, Section } from 'tgui/components';
type Data = {
feeds: feed[];

View File

@@ -1,6 +1,6 @@
/* eslint react/no-danger: "off" */
import { useBackend } from '../../backend';
import { Box, Button, Section, Table } from '../../components';
import { useBackend } from 'tgui/backend';
import { Box, Button, Section, Table } from 'tgui/components';
type Data = { note: string; notename: string };

View File

@@ -0,0 +1,5 @@
import { PowerMonitorContent } from '../../PowerMonitor/PowerMonitorContent';
export const pda_power = (props) => {
return <PowerMonitorContent />;
};

View File

@@ -1,6 +1,7 @@
import { useBackend } from '../../backend';
import { Box, Button, LabeledList, Section } from '../../components';
import { GeneralRecord, RecordList, SecurityRecord } from './pda_types';
import { useBackend } from 'tgui/backend';
import { Box, Button, LabeledList, Section } from 'tgui/components';
import { GeneralRecord, RecordList, SecurityRecord } from '../pda_types';
type Data = {
records: {

View File

@@ -1,4 +1,4 @@
import { SignalerContent } from '../Signaler';
import { SignalerContent } from '../../Signaler';
export const pda_signaller = (props) => {
return <SignalerContent />;

View File

@@ -1,5 +1,5 @@
import { useBackend } from '../../backend';
import { Box, Button, LabeledList } from '../../components';
import { useBackend } from 'tgui/backend';
import { Box, Button, LabeledList } from 'tgui/components';
type Data = { records: { message1: string; message2: string } | null };

View File

@@ -1,5 +1,5 @@
import { useBackend } from '../../backend';
import { Box, LabeledList, Section } from '../../components';
import { useBackend } from 'tgui/backend';
import { Box, LabeledList, Section } from 'tgui/components';
type Data = {
supply: {

View File

@@ -1,4 +1,4 @@
import { IconProps } from '../../components/Icon';
import { IconProps } from 'tgui/components/Icon';
/**
* Gernal Record data
*/

View File

@@ -72,3 +72,11 @@ export const AUTO_AFK: FeatureToggle = {
'When enabled, you will automatically be marked as AFK if you are idle for too long.',
component: CheckboxInput,
};
export const MessengerEmbeds: FeatureToggle = {
name: 'Messenger Embeds',
category: 'UI',
description:
'When enabled, PDAs and Communicators will attempt to embed links from discord & imgur.',
component: CheckboxInput,
};

View File

@@ -3,7 +3,7 @@ import { BooleanLike } from 'common/react';
import { useBackend } from '../backend';
import { Box, Button, LabeledList, Section } from '../components';
import { Window } from '../layouts';
import { GeneralRecord, MedicalRecord, RecordList } from './pda/pda_types';
import { GeneralRecord, MedicalRecord, RecordList } from './Pda/pda_types';
type Data = {
records: RecordList;

View File

@@ -3,7 +3,7 @@ import { BooleanLike } from 'common/react';
import { useBackend } from '../backend';
import { Box, Button, LabeledList, Section } from '../components';
import { Window } from '../layouts';
import { GeneralRecord, RecordList, SecurityRecord } from './pda/pda_types';
import { GeneralRecord, RecordList, SecurityRecord } from './Pda/pda_types';
type Data = {
records: RecordList;

View File

@@ -1,292 +0,0 @@
import { filter } from 'common/collections';
import { BooleanLike } from 'common/react';
import { decodeHtmlEntities } from 'common/string';
import { useState } from 'react';
import { useBackend } from '../../backend';
import { Box, Button, LabeledList, Section } from '../../components';
type Data = {
active_conversation: string;
convo_name: string;
convo_job: string;
messages: message[];
toff: BooleanLike;
silent: BooleanLike;
convopdas: pda[];
pdas: pda[];
charges: number;
plugins: { name: string; icon: string; ref: string }[];
};
type pda = {
Name: string;
Reference: string;
Detonate: string;
inconvo: string;
};
type message = {
sent: BooleanLike;
owner: string;
job: string;
message: string;
target: string;
};
export const pda_messenger = (props) => {
const { act, data } = useBackend<Data>();
const { active_conversation } = data;
if (active_conversation) {
return <ActiveConversation />;
}
return <MessengerList />;
};
const findClassMessage = (im, lastIndex, filterArray) => {
if (lastIndex < 0 || lastIndex > filterArray.length) {
return im.sent
? 'TinderMessage_First_Sent'
: 'TinderMessage_First_Received';
}
let lastSent = filterArray[lastIndex].sent;
if (im.sent && lastSent) {
return 'TinderMessage_Subsequent_Sent';
} else if (!im.sent && !lastSent) {
return 'TinderMessage_Subsequent_Received';
}
return im.sent ? 'TinderMessage_First_Sent' : 'TinderMessage_First_Received';
};
const ActiveConversation = (props) => {
const { act, data } = useBackend<Data>();
const { convo_name, convo_job, messages, active_conversation } = data;
const [clipboardMode, setClipboardMode] = useState(false);
let body = (
<Section
title={'Conversation with ' + convo_name + ' (' + convo_job + ')'}
buttons={
<Button
icon="eye"
selected={clipboardMode}
tooltip="Enter Clipboard Mode"
tooltipPosition="bottom-end"
onClick={() => setClipboardMode(!clipboardMode)}
/>
}
height="450px"
stretchContents
>
<Button
icon="comment"
onClick={() => act('Message', { target: active_conversation })}
>
Reply
</Button>
<Section
style={{
height: '97%',
overflowY: 'auto',
}}
>
{filter(
messages,
(im: message) => im.target === active_conversation,
).map((im, i, filterArr) => (
<Box textAlign={im.sent ? 'right' : 'left'} mb={1} key={i}>
<Box
maxWidth="75%"
className={findClassMessage(im, i - 1, filterArr)}
inline
>
{decodeHtmlEntities(im.message)}
</Box>
</Box>
))}
</Section>
<Button
icon="comment"
onClick={() => act('Message', { target: active_conversation })}
>
Reply
</Button>
</Section>
);
if (clipboardMode) {
body = (
<Section
title={'Conversation with ' + convo_name + ' (' + convo_job + ')'}
buttons={
<Button
icon="eye"
selected={clipboardMode}
tooltip="Exit Clipboard Mode"
tooltipPosition="bottom-end"
onClick={() => setClipboardMode(!clipboardMode)}
/>
}
height="450px"
stretchContents
>
<Button
icon="comment"
onClick={() => act('Message', { target: active_conversation })}
>
Reply
</Button>
<Section
style={{
height: '97%',
overflowY: 'auto',
}}
>
{filter(
messages,
(im: message) => im.target === active_conversation,
).map((im, i) => (
<Box
key={i}
className={
im.sent ? 'ClassicMessage_Sent' : 'ClassicMessage_Received'
}
>
{im.sent ? 'You:' : 'Them:'} {decodeHtmlEntities(im.message)}
</Box>
))}
</Section>
<Button
icon="comment"
onClick={() => act('Message', { target: active_conversation })}
>
Reply
</Button>
</Section>
);
}
return (
<Box>
<LabeledList>
<LabeledList.Item label="Messenger Functions">
<Button
icon="trash"
color="bad"
onClick={() => act('Clear', { option: 'Convo' })}
>
Delete Conversations
</Button>
</LabeledList.Item>
</LabeledList>
{body}
</Box>
);
};
const MessengerList = (props) => {
const { act, data } = useBackend<Data>();
const { convopdas, pdas, charges, silent, toff } = data;
return (
<Box>
<LabeledList>
<LabeledList.Item label="Messenger Functions">
<Button
selected={!silent}
icon={silent ? 'volume-mute' : 'volume-up'}
onClick={() => act('Toggle Ringer')}
>
Ringer: {silent ? 'Off' : 'On'}
</Button>
<Button
color={toff ? 'bad' : 'green'}
icon="power-off"
onClick={() => act('Toggle Messenger')}
>
Messenger: {toff ? 'Off' : 'On'}
</Button>
<Button icon="bell" onClick={() => act('Ringtone')}>
Set Ringtone
</Button>
<Button
icon="trash"
color="bad"
onClick={() => act('Clear', { option: 'All' })}
>
Delete All Conversations
</Button>
</LabeledList.Item>
</LabeledList>
{(!toff && (
<Box>
{!!charges && <Box>{charges} charges left.</Box>}
{(!convopdas.length && !pdas.length && (
<Box>No other PDAs located.</Box>
)) || (
<Box>
<PDAList
title="Current Conversations"
pdas={convopdas}
msgAct="Select Conversation"
/>
<PDAList title="Other PDAs" pdas={pdas} msgAct="Message" />
</Box>
)}
</Box>
)) || (
<Box color="bad" mt={2}>
Messenger Offline.
</Box>
)}
</Box>
);
};
const PDAList = (props) => {
const { act, data } = useBackend<Data>();
const { pdas, title, msgAct } = props;
const { charges, plugins } = data;
if (!pdas || !pdas.length) {
return <Section title={title}>No PDAs found.</Section>;
}
return (
<Section title={title}>
{pdas.map((pda) => (
<Box key={pda.Reference}>
<Button
icon="arrow-circle-down"
onClick={() => act(msgAct, { target: pda.Reference })}
>
{pda.Name}
</Button>
{!!charges &&
plugins.map((plugin) => (
<Button
key={plugin.ref}
icon={plugin.icon}
onClick={() =>
act('Messenger Plugin', {
plugin: plugin.ref,
target: pda.Reference,
})
}
>
{plugin.name}
</Button>
))}
</Box>
))}
</Section>
);
};

View File

@@ -1,5 +0,0 @@
import { PowerMonitorContent } from '../PowerMonitor/PowerMonitorContent';
export const pda_power = (props) => {
return <PowerMonitorContent />;
};