, assigned at time of request creation
+ var/owner_name
+ /// The message associated with the request
+ var/message
+ /// When the request was created
+ var/timestamp
+
+/datum/request/New(client/requestee, type, request)
+ if (!requestee)
+ qdel(src)
+ return
+ id = ++atomic_id
+ owner = requestee
+ owner_ckey = owner.ckey
+ req_type = type
+ message = request
+ timestamp = world.time
+ owner_name = key_name(requestee, FALSE)
diff --git a/code/modules/requests/request_manager.dm b/code/modules/requests/request_manager.dm
new file mode 100644
index 00000000000..de8fc5e0f63
--- /dev/null
+++ b/code/modules/requests/request_manager.dm
@@ -0,0 +1,213 @@
+GLOBAL_DATUM_INIT(requests, /datum/request_manager, new)
+
+/**
+ * # Request Manager
+ *
+ * Handles all player requests (prayers, centcom requests, syndicate requests)
+ * that occur in the duration of a round.
+ */
+/datum/request_manager
+ /// Associative list of ckey -> list of requests
+ var/list/requests = list()
+ /// List where requests can be accessed by ID
+ var/list/requests_by_id = list()
+
+/datum/request_manager/Destroy(force, ...)
+ QDEL_LIST(requests)
+ return ..()
+
+/**
+ * Used in the new client pipeline to catch when clients are reconnecting and need to have their
+ * reference re-assigned to the 'owner' variable of any requests
+ *
+ * Arguments:
+ * * C - The client who is logging in
+ */
+/datum/request_manager/proc/client_login(client/C)
+ if (!requests[C.ckey])
+ return
+ for (var/datum/request/request as anything in requests[C.ckey])
+ request.owner = C
+
+/**
+ * Used in the destroy client pipeline to catch when clients are disconnecting and need to have their
+ * reference nulled on the 'owner' variable of any requests
+ *
+ * Arguments:
+ * * C - The client who is logging out
+ */
+/datum/request_manager/proc/client_logout(client/C)
+ if (!requests[C.ckey])
+ return
+ for (var/datum/request/request as anything in requests[C.ckey])
+ request.owner = null
+
+/**
+ * Creates a request for a prayer, and notifies admins who have the sound notifications enabled when appropriate
+ *
+ * Arguments:
+ * * C - The client who is praying
+ * * message - The prayer
+ * * is_chaplain - Boolean operator describing if the prayer is from a chaplain
+ */
+/datum/request_manager/proc/pray(client/C, message, is_chaplain)
+ request_for_client(C, REQUEST_PRAYER, message)
+ for(var/client/admin in GLOB.admins)
+ if(is_chaplain && admin.prefs.chat_toggles & CHAT_PRAYER && admin.prefs.toggles & SOUND_PRAYERS)
+ SEND_SOUND(admin, sound('sound/effects/pray.ogg'))
+
+/**
+ * Creates a request for a Centcom message
+ *
+ * Arguments:
+ * * C - The client who is sending the request
+ * * message - The message
+ */
+/datum/request_manager/proc/message_centcom(client/C, message)
+ request_for_client(C, REQUEST_CENTCOM, message)
+
+/**
+ * Creates a request for a Syndicate message
+ *
+ * Arguments:
+ * * C - The client who is sending the request
+ * * message - The message
+ */
+/datum/request_manager/proc/message_syndicate(client/C, message)
+ request_for_client(C, REQUEST_SYNDICATE, message)
+
+/**
+ * Creates a request for the nuclear self destruct codes
+ *
+ * Arguments:
+ * * C - The client who is sending the request
+ * * message - The message
+ */
+/datum/request_manager/proc/nuke_request(client/C, message)
+ request_for_client(C, REQUEST_NUKE, message)
+
+/**
+ * Creates a request and registers the request with all necessary internal tracking lists
+ *
+ * Arguments:
+ * * C - The client who is sending the request
+ * * type - The type of request, see defines
+ * * message - The message
+ */
+/datum/request_manager/proc/request_for_client(client/C, type, message)
+ var/datum/request/request = new(C, type, message)
+ if (!requests[C.ckey])
+ requests[C.ckey] = list()
+ requests[C.ckey] += request
+ requests_by_id.len++
+ requests_by_id[request.id] = request
+
+/datum/request_manager/ui_interact(mob/user, datum/tgui/ui = null)
+ ui = SStgui.try_update_ui(user, src, ui)
+ if (!ui)
+ ui = new(user, src, "RequestManager")
+ ui.open()
+
+/datum/request_manager/ui_state(mob/user)
+ return GLOB.admin_state
+
+/datum/request_manager/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ if (..())
+ return
+
+ // Only admins should be sending actions
+ if (!check_rights(R_ADMIN))
+ to_chat(usr, "You do not have permission to do this, you require +ADMIN", confidential = TRUE)
+ return
+
+ // Get the request this relates to
+ var/id = params["id"] != null ? text2num(params["id"]) : null
+ if (!id)
+ to_chat(usr, "Failed to find a request ID in your action, please report this", confidential = TRUE)
+ CRASH("Received an action without a request ID, this shouldn't happen!")
+ var/datum/request/request = !id ? null : requests_by_id[id]
+
+ switch(action)
+ if ("pp")
+ var/mob/M = request.owner?.mob
+ usr.client.holder.show_player_panel(M)
+ return TRUE
+ if ("vv")
+ var/mob/M = request.owner?.mob
+ usr.client.debug_variables(M)
+ return TRUE
+ if ("sm")
+ var/mob/M = request.owner?.mob
+ usr.client.cmd_admin_subtle_message(M)
+ return TRUE
+ if ("flw")
+ var/mob/M = request.owner?.mob
+ usr.client.admin_follow(M)
+ return TRUE
+ if ("tp")
+ if(!SSticker.HasRoundStarted())
+ tgui_alert(usr,"The game hasn't started yet!")
+ return TRUE
+ var/mob/M = request.owner?.mob
+ if(!ismob(M))
+ var/datum/mind/D = M
+ if(!istype(D))
+ to_chat(usr, "This can only be used on instances of type /mob and /mind", confidential = TRUE)
+ return TRUE
+ else
+ D.traitor_panel()
+ return TRUE
+ else
+ usr.client.holder.show_traitor_panel(M)
+ return TRUE
+ if ("logs")
+ var/mob/M = request.owner?.mob
+ if(!ismob(M))
+ to_chat(usr, "This can only be used on instances of type /mob.", confidential = TRUE)
+ return TRUE
+ show_individual_logging_panel(M, null, null)
+ return TRUE
+ if ("smite")
+ if(!check_rights(R_FUN))
+ to_chat(usr, "Insufficient permissions to smite, you require +FUN", confidential = TRUE)
+ return TRUE
+ var/mob/living/carbon/human/H = request.owner?.mob
+ if (!H || !istype(H))
+ to_chat(usr, "This can only be used on instances of type /mob/living/carbon/human", confidential = TRUE)
+ return TRUE
+ usr.client.smite(H)
+ return TRUE
+ if ("rply")
+ if (request.req_type == REQUEST_PRAYER)
+ to_chat(usr, "Cannot reply to a prayer", confidential = TRUE)
+ return TRUE
+ var/mob/M = request.owner?.mob
+ usr.client.admin_headset_message(M, request.req_type == REQUEST_SYNDICATE ? RADIO_CHANNEL_SYNDICATE : RADIO_CHANNEL_CENTCOM)
+ return TRUE
+ if ("setcode")
+ if (request.req_type != REQUEST_NUKE)
+ to_chat(usr, "You cannot set the nuke code for a non-nuke-code-request request!", confidential = TRUE)
+ return TRUE
+ var/code = random_nukecode()
+ for(var/obj/machinery/nuclearbomb/selfdestruct/SD in GLOB.nuke_list)
+ SD.r_code = code
+ message_admins("[key_name_admin(usr)] has set the self-destruct code to \"[code]\".")
+ return TRUE
+
+/datum/request_manager/ui_data(mob/user)
+ . = list(
+ "requests" = list()
+ )
+ for (var/ckey in requests)
+ for (var/datum/request/request as anything in requests[ckey])
+ var/list/data = list(
+ "id" = request.id,
+ "req_type" = request.req_type,
+ "owner" = request.owner ? "[REF(request.owner)]" : null,
+ "owner_ckey" = request.owner_ckey,
+ "owner_name" = request.owner_name,
+ "message" = request.message,
+ "timestamp" = request.timestamp,
+ "timestamp_str" = gameTimestamp(wtime = request.timestamp)
+ )
+ .["requests"] += list(data)
diff --git a/tgstation.dme b/tgstation.dme
index f46712443bb..bfa7938e826 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -1690,6 +1690,7 @@
#include "code\modules\admin\verbs\possess.dm"
#include "code\modules\admin\verbs\pray.dm"
#include "code\modules\admin\verbs\reestablish_db_connection.dm"
+#include "code\modules\admin\verbs\requests.dm"
#include "code\modules\admin\verbs\secrets.dm"
#include "code\modules\admin\verbs\selectequipment.dm"
#include "code\modules\admin\verbs\server.dm"
@@ -3370,6 +3371,8 @@
#include "code\modules\religion\religion_sects.dm"
#include "code\modules\religion\religion_structures.dm"
#include "code\modules\religion\rites.dm"
+#include "code\modules\requests\request.dm"
+#include "code\modules\requests\request_manager.dm"
#include "code\modules\research\bepis.dm"
#include "code\modules\research\designs.dm"
#include "code\modules\research\destructive_analyzer.dm"
diff --git a/tgui/packages/tgui/interfaces/RequestManager.js b/tgui/packages/tgui/interfaces/RequestManager.js
new file mode 100644
index 00000000000..49dff93ee11
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/RequestManager.js
@@ -0,0 +1,177 @@
+/**
+ * @file
+ * @copyright 2021 bobbahbrown (https://github.com/bobbahbrown)
+ * @license MIT
+ */
+
+import { decodeHtmlEntities } from 'common/string';
+import { useBackend, useLocalState } from '../backend';
+import { Button, Input, Section, Table } from '../components';
+import { Popper } from '../components/Popper';
+import { Window } from '../layouts';
+
+export const RequestManager = (props, context) => {
+ const { act, data } = useBackend(context);
+ const { requests } = data;
+ const [filteredTypes, _] = useLocalState(
+ context,
+ 'filteredTypes',
+ Object.fromEntries(
+ Object.entries(displayTypeMap).map(([type, _]) => [type, true])
+ )
+ );
+ const [searchText, setSearchText] = useLocalState(context, 'searchText');
+
+ // Handle filtering
+ let displayedRequests = requests.filter(
+ (request) => filteredTypes[request.req_type]
+ );
+ if (searchText) {
+ const filterText = searchText.toLowerCase();
+ displayedRequests = displayedRequests.filter(
+ (request) =>
+ decodeHtmlEntities(request.message)
+ .toLowerCase()
+ .includes(filterText)
+ || request.owner_name.toLowerCase().includes(filterText)
+ );
+ }
+
+ return (
+
+
+
+ setSearchText(value)}
+ placeholder={'Search...'}
+ mr={1}
+ />
+
+ >
+ }>
+ {displayedRequests.map((request) => (
+
+
+
+
+ {request.owner_name}
+ {request.owner === null && ' [DC]'}
+
+
+ {request.timestamp_str}
+
+
+
+
+ {decodeHtmlEntities(request.message)}
+
+
+ {request.owner !== null &&
}
+
+ ))}
+
+
+
+ );
+};
+
+const displayTypeMap = {
+ 'request_prayer': 'PRAYER',
+ 'request_centcom': 'CENTCOM',
+ 'request_syndicate': 'SYNDICATE',
+ 'request_nuke': 'NUKE CODE',
+};
+
+const RequestType = (props) => {
+ const { requestType } = props;
+
+ return (
+
+ {displayTypeMap[requestType]}:
+
+ );
+};
+
+const RequestControls = (props, context) => {
+ const { act, _ } = useBackend(context);
+ const { request } = props;
+
+ return (
+
+
+
+
+
+
+
+
+ {request.req_type !== 'request_prayer' && (
+
+ )}
+ {request.req_type === 'request_nuke' && (
+
+ )}
+
+ );
+};
+
+const FilterPanel = (_, context) => {
+ const [filterVisible, setFilterVisible] = useLocalState(
+ context,
+ 'filterVisible',
+ false
+ );
+ const [filteredTypes, setFilteredTypes] = useLocalState(
+ context,
+ 'filteredTypes',
+ Object.fromEntries(
+ Object.entries(displayTypeMap).map(([type, _]) => [type, true])
+ )
+ );
+
+ return (
+
+
+ {Object.keys(displayTypeMap).map((type) => {
+ return (
+
+
+
+
+
+ {
+ filteredTypes[type] = !filteredTypes[type];
+ setFilteredTypes(filteredTypes);
+ }}
+ my={0.25}
+ />
+
+
+ );
+ })}
+
+
+ }>
+
+
+ );
+};
diff --git a/tgui/packages/tgui/styles/interfaces/RequestManager.scss b/tgui/packages/tgui/styles/interfaces/RequestManager.scss
new file mode 100644
index 00000000000..8e113c4fb6e
--- /dev/null
+++ b/tgui/packages/tgui/styles/interfaces/RequestManager.scss
@@ -0,0 +1,106 @@
+/**
+ * Copyright (c) 2021 bobbahbrown (https://github.com/bobbahbrown)
+ * SPDX-License-Identifier: MIT
+ */
+
+@use '../base.scss';
+@use '../colors.scss';
+@use '../functions.scss';
+
+$color-prayer: colors.bg(colors.$purple) !default;
+$color-centcom: colors.bg(colors.$yellow) !default;
+$color-syndicate: colors.bg(colors.$red) !default;
+$color-nuke: colors.bg(colors.$yellow) !default;
+$color-muted: colors.bg(colors.$label) !default;
+$color-header: colors.bg(colors.$label) !default;
+$background-color: base.$color-bg-section !default;
+$text-color: base.$color-fg !default;
+
+.RequestManager__request_centcom,
+.RequestManager__request_nuke,
+.RequestManager__request_prayer,
+.RequestManager__request_syndicate {
+ margin-right: 0.5rem;
+}
+
+.RequestManager__request_prayer {
+ color: $color-prayer;
+}
+
+.RequestManager__request_centcom {
+ color: $color-centcom;
+}
+
+.RequestManager__request_syndicate {
+ color: $color-syndicate;
+}
+
+.RequestManager__request_nuke {
+ color: $color-nuke;
+}
+
+.RequestManager__header {
+ line-height: 1.375rem;
+ min-height: 1.375rem;
+ color: $color-muted;
+ white-space: break-spaces;
+ word-wrap: pre-wrap;
+ vertical-align: baseline;
+ margin-bottom: 0;
+ padding-bottom: 0.1rem;
+}
+
+.RequestManager__headerText {
+ margin-right: 0.5rem;
+ font-size: 1rem;
+ font-weight: bold;
+ color: $color-header;
+}
+
+.RequestManager__timestamp {
+ font-size: 0.75rem;
+}
+
+.RequestManager__row:first-of-type {
+ margin-top: 0rem;
+}
+
+.RequestManager__row {
+ padding-top: 0.125rem;
+ padding-bottom: 0.125rem;
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+ margin-top: 0.75rem;
+ min-height: 2.75rem;
+}
+
+.RequestManager__row:hover {
+ background-color: rgba(0, 0, 0, 0.25);
+}
+
+.RequestManager__row:hover .RequestManager__controlsContainer {
+ opacity: 1;
+}
+
+.RequestManager__controlsContainer {
+ opacity: 0;
+ position: absolute;
+ right: 3px;
+ top: 3px;
+}
+
+.RequestManager__row {
+ position: relative;
+}
+
+.RequestManager__filterPanel {
+ margin: 0.5rem;
+ padding: 0.5rem;
+ background-color: functions.fake-alpha($background-color, base.$color-bg);
+ background-color: $background-color;
+ color: $text-color
+}
+
+.RequestManager__filterPanel tr:not(:last-of-type) {
+ margin-bottom: 0.5rem;
+}
diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss
index e9b2c86e1eb..1008e421025 100644
--- a/tgui/packages/tgui/styles/main.scss
+++ b/tgui/packages/tgui/styles/main.scss
@@ -56,6 +56,7 @@
@include meta.load-css('./interfaces/Safe.scss');
@include meta.load-css('./interfaces/TachyonArray.scss');
@include meta.load-css('./interfaces/Techweb.scss');
+@include meta.load-css('./interfaces/RequestManager.scss');
// Layouts
@include meta.load-css('./layouts/Layout.scss');