diff --git a/docker/node/modules/auth/actions.js b/docker/node/modules/auth/actions.js
index f8f7791e..8e61ff84 100644
--- a/docker/node/modules/auth/actions.js
+++ b/docker/node/modules/auth/actions.js
@@ -29,4 +29,5 @@ exports.actions = {
CAN_SEE_OBSCURED_POLLS: "canSeeObscuredPolls",
CAN_SEE_PRIVILEGED_USER_DATA: "canSeePrivilegedUserData",
CAN_SEE_SHADOWBANS: "canSeeShadowbans",
+ ACTION_POST_IMAGE: "postImage",
};
diff --git a/docker/node/modules/auth/service.js b/docker/node/modules/auth/service.js
index b2e3a271..f04f1fbc 100644
--- a/docker/node/modules/auth/service.js
+++ b/docker/node/modules/auth/service.js
@@ -35,6 +35,7 @@ exports.AuthService = class extends ServiceBase {
[actions.CAN_SEE_PRIVILEGED_USER_DATA]: isMod,
[actions.CAN_SEE_SHADOWBANS]: isMod,
[actions.ACTION_CAN_RESET_PASSWORD]: isAdmin,
+ [actions.ACTION_POST_IMAGE]: isMod,
};
function isBerry({ isBerry }) {
diff --git a/docker/node/server.js b/docker/node/server.js
index 31d47bce..68101aa4 100644
--- a/docker/node/server.js
+++ b/docker/node/server.js
@@ -1069,6 +1069,50 @@ const chatCommandMap = {
);
}
}),
+ ...withAliases(["img", "image", "video", "media"], (parsed, socket, messageData) => {
+ if (!authService.can(socket.session, actions.ACTION_POST_IMAGE)) {
+ kickForIllegalActivity(socket);
+ return doSuppressChat;
+ }
+
+ let format = parsed.msg.trim().split('.').pop();
+
+ if (format.includes('?')) {
+ format = format.split('?')[0];
+ }
+
+ const supportedFormats = new Map([
+ //images
+ ['png', {kind: 'image'}],
+ ['jpg', {kind: 'image'}],
+ ['jpeg', {kind: 'image'}],
+ ['gif', {kind: 'image'}],
+ ['svg', {kind: 'image'}],
+ ['webp', {kind: 'image'}],
+ ['avif', {kind: 'image'}],
+
+ //videos
+ ['mp4', {kind: 'video'}],
+ ['webm', {kind: 'video'}],
+ ['gifv', {kind: 'video', real: 'mp4'}]
+ ])
+
+
+ //couldn't get file extension or media not supported, don't show an attempt
+ if (format === '' || !supportedFormats.has(format)) {
+ return doSuppressChat;
+ }
+
+ const info = supportedFormats.get(format);
+
+ if (info.real) {
+ messageData.msg = parsed.msg.replace(`.${format}`, `.${info.real}`);
+ }
+
+ messageData.emote = info.kind;
+
+ return doNormalChatMessage;
+ }),
};
function _sendChat(nick, type, incoming, socket) {
diff --git a/web/css/layout-other.css b/web/css/layout-other.css
index 36799e79..3a634272 100644
--- a/web/css/layout-other.css
+++ b/web/css/layout-other.css
@@ -664,7 +664,9 @@ body {
}
/* mod-embedded images */
-.img-filter > img {
+#chatbuffer .img-filter > img,
+#chatbuffer .image > img,
+#chatbuffer video {
max-height: 150px;
max-width: 100%;
}
diff --git a/web/js/functions.js b/web/js/functions.js
index da055e11..fb2e22be 100644
--- a/web/js/functions.js
+++ b/web/js/functions.js
@@ -1363,6 +1363,47 @@ function addChatMsg(data, _to) {
newmsg.addClass("server").appendTo(msgwrap);
$("").appendTo(newmsg).html(msgText);
break;
+ case "image":
+ const linkAttrs = {
+ href: msgText,
+ class: 'image',
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ };
+
+ newmsg.addClass("message").append(
+ $('', {class: `nick`, nick, text: `${nick}:`}),
+ $('', linkAttrs).append(
+ $('
', {
+ src: msgText,
+ referrerpolicy: "no-referrer",
+ alt: 'Loading image...',
+ onload: "scrollBuffersToBottom()"
+ })
+ )
+ ).appendTo(msgwrap);
+
+ includeTimestamp = true;
+ break;
+ case "video": {
+ const attributes = {
+ autoplay: '',
+ loop: '',
+ muted: '',
+ src: msgText,
+ referrerpolicy: 'noreferrer',
+ alt: 'Loading video...',
+ onload: "scrollBuffersToBottom()"
+ };
+
+ newmsg.addClass("message").append(
+ $('', {class: `nick`, nick, text: `${nick}:`}),
+ $('