Init commit Fivem Data

This commit is contained in:
jack
2026-06-20 13:18:14 +00:00
commit b8dd09ae66
137 changed files with 161716 additions and 0 deletions

4
resources/[gameplay]/chat/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.yarn.installed
yarn-error.log
dist/

View File

@@ -0,0 +1 @@
# Chat

View File

@@ -0,0 +1,308 @@
local isRDR = not TerraingridActivate and true or false
local chatInputActive = false
local chatInputActivating = false
local chatLoaded = false
RegisterNetEvent('chatMessage')
RegisterNetEvent('chat:addTemplate')
RegisterNetEvent('chat:addMessage')
RegisterNetEvent('chat:addSuggestion')
RegisterNetEvent('chat:addSuggestions')
RegisterNetEvent('chat:addMode')
RegisterNetEvent('chat:removeMode')
RegisterNetEvent('chat:removeSuggestion')
RegisterNetEvent('chat:clear')
-- internal events
RegisterNetEvent('__cfx_internal:serverPrint')
RegisterNetEvent('_chat:messageEntered')
--deprecated, use chat:addMessage
AddEventHandler('chatMessage', function(author, color, text)
local args = { text }
if author ~= "" then
table.insert(args, 1, author)
end
SendNUIMessage({
type = 'ON_MESSAGE',
message = {
color = color,
multiline = true,
args = args
}
})
end)
AddEventHandler('__cfx_internal:serverPrint', function(msg)
print(msg)
SendNUIMessage({
type = 'ON_MESSAGE',
message = {
templateId = 'print',
multiline = true,
args = { msg },
mode = '_global'
}
})
end)
-- addMessage
local addMessage = function(message)
if type(message) == 'string' then
message = {
args = { message }
}
end
SendNUIMessage({
type = 'ON_MESSAGE',
message = message
})
end
exports('addMessage', addMessage)
AddEventHandler('chat:addMessage', addMessage)
-- addSuggestion
local addSuggestion = function(name, help, params)
SendNUIMessage({
type = 'ON_SUGGESTION_ADD',
suggestion = {
name = name,
help = help,
params = params or nil
}
})
end
exports('addSuggestion', addSuggestion)
AddEventHandler('chat:addSuggestion', addSuggestion)
AddEventHandler('chat:addSuggestions', function(suggestions)
for _, suggestion in ipairs(suggestions) do
SendNUIMessage({
type = 'ON_SUGGESTION_ADD',
suggestion = suggestion
})
end
end)
AddEventHandler('chat:removeSuggestion', function(name)
SendNUIMessage({
type = 'ON_SUGGESTION_REMOVE',
name = name
})
end)
AddEventHandler('chat:addMode', function(mode)
SendNUIMessage({
type = 'ON_MODE_ADD',
mode = mode
})
end)
AddEventHandler('chat:removeMode', function(name)
SendNUIMessage({
type = 'ON_MODE_REMOVE',
name = name
})
end)
AddEventHandler('chat:addTemplate', function(id, html)
SendNUIMessage({
type = 'ON_TEMPLATE_ADD',
template = {
id = id,
html = html
}
})
end)
AddEventHandler('chat:clear', function(name)
SendNUIMessage({
type = 'ON_CLEAR'
})
end)
RegisterNUICallback('chatResult', function(data, cb)
chatInputActive = false
SetNuiFocus(false)
if not data.canceled then
local id = PlayerId()
--deprecated
local r, g, b = 0, 0x99, 255
if data.message:sub(1, 1) == '/' then
ExecuteCommand(data.message:sub(2))
else
TriggerServerEvent('_chat:messageEntered', GetPlayerName(id), { r, g, b }, data.message, data.mode)
end
end
cb('ok')
end)
local function refreshCommands()
if GetRegisteredCommands then
local registeredCommands = GetRegisteredCommands()
local suggestions = {}
for _, command in ipairs(registeredCommands) do
if IsAceAllowed(('command.%s'):format(command.name)) and command.name ~= 'toggleChat' then
table.insert(suggestions, {
name = '/' .. command.name,
help = ''
})
end
end
TriggerEvent('chat:addSuggestions', suggestions)
end
end
local function refreshThemes()
local themes = {}
for resIdx = 0, GetNumResources() - 1 do
local resource = GetResourceByFindIndex(resIdx)
if GetResourceState(resource) == 'started' then
local numThemes = GetNumResourceMetadata(resource, 'chat_theme')
if numThemes > 0 then
local themeName = GetResourceMetadata(resource, 'chat_theme')
local themeData = json.decode(GetResourceMetadata(resource, 'chat_theme_extra') or 'null')
if themeName and themeData then
themeData.baseUrl = 'nui://' .. resource .. '/'
themes[themeName] = themeData
end
end
end
end
SendNUIMessage({
type = 'ON_UPDATE_THEMES',
themes = themes
})
end
AddEventHandler('onClientResourceStart', function(resName)
Wait(500)
refreshCommands()
refreshThemes()
end)
AddEventHandler('onClientResourceStop', function(resName)
Wait(500)
refreshCommands()
refreshThemes()
end)
RegisterNUICallback('loaded', function(data, cb)
TriggerServerEvent('chat:init')
refreshCommands()
refreshThemes()
chatLoaded = true
cb('ok')
end)
local CHAT_HIDE_STATES = {
SHOW_WHEN_ACTIVE = 0,
ALWAYS_SHOW = 1,
ALWAYS_HIDE = 2
}
local kvpEntry = GetResourceKvpString('hideState')
local chatHideState = kvpEntry and tonumber(kvpEntry) or CHAT_HIDE_STATES.SHOW_WHEN_ACTIVE
local isFirstHide = true
if not isRDR then
if RegisterKeyMapping then
RegisterKeyMapping('toggleChat', 'Toggle chat', 'keyboard', 'l')
end
RegisterCommand('toggleChat', function()
if chatHideState == CHAT_HIDE_STATES.SHOW_WHEN_ACTIVE then
chatHideState = CHAT_HIDE_STATES.ALWAYS_SHOW
elseif chatHideState == CHAT_HIDE_STATES.ALWAYS_SHOW then
chatHideState = CHAT_HIDE_STATES.ALWAYS_HIDE
elseif chatHideState == CHAT_HIDE_STATES.ALWAYS_HIDE then
chatHideState = CHAT_HIDE_STATES.SHOW_WHEN_ACTIVE
end
isFirstHide = false
SetResourceKvp('hideState', tostring(chatHideState))
end, false)
end
Citizen.CreateThread(function()
SetTextChatEnabled(false)
SetNuiFocus(false)
local lastChatHideState = -1
local origChatHideState = -1
while true do
Wait(0)
if not chatInputActive then
if IsControlPressed(0, isRDR and `INPUT_MP_TEXT_CHAT_ALL` or 245) --[[ INPUT_MP_TEXT_CHAT_ALL ]] then
chatInputActive = true
chatInputActivating = true
SendNUIMessage({
type = 'ON_OPEN'
})
end
end
if chatInputActivating then
if not IsControlPressed(0, isRDR and `INPUT_MP_TEXT_CHAT_ALL` or 245) then
SetNuiFocus(true)
chatInputActivating = false
end
end
if chatLoaded then
local forceHide = IsScreenFadedOut() or IsPauseMenuActive()
local wasForceHide = false
if chatHideState ~= CHAT_HIDE_STATES.ALWAYS_HIDE then
if forceHide then
origChatHideState = chatHideState
chatHideState = CHAT_HIDE_STATES.ALWAYS_HIDE
end
elseif not forceHide and origChatHideState ~= -1 then
chatHideState = origChatHideState
origChatHideState = -1
wasForceHide = true
end
if chatHideState ~= lastChatHideState then
lastChatHideState = chatHideState
SendNUIMessage({
type = 'ON_SCREEN_STATE_CHANGE',
hideState = chatHideState,
fromUserInteraction = not forceHide and not isFirstHide and not wasForceHide
})
isFirstHide = false
end
end
end
end)

View File

@@ -0,0 +1,30 @@
-- This resource is part of the default Cfx.re asset pack (cfx-server-data)
-- Altering or recreating for local use only is strongly discouraged.
version '1.0.0'
author 'Cfx.re <root@cfx.re>'
description 'Provides baseline chat functionality using a NUI-based interface.'
repository 'https://github.com/citizenfx/cfx-server-data'
ui_page 'dist/ui.html'
client_script 'cl_chat.lua'
server_script 'sv_chat.lua'
files {
'dist/ui.html',
'dist/index.css',
'html/vendor/*.css',
'html/vendor/fonts/*.woff2',
}
fx_version 'adamant'
games { 'rdr3', 'gta5' }
rdr3_warning 'I acknowledge that this is a prerelease build of RedM, and I am aware my resources *will* become incompatible once RedM ships.'
dependencies {
'yarn',
'webpack'
}
webpack_config 'webpack.config.js'

View File

@@ -0,0 +1,467 @@
import { post } from './utils';
import CONFIG from './config';
import Vue from 'vue';
import Suggestions from './Suggestions.vue';
import MessageV from './Message.vue';
import { Suggestion } from './Suggestions';
export interface Message {
args: string[];
template: string;
params?: { [key: string]: string };
multiline?: boolean;
color?: [ number, number, number ];
templateId?: number;
mode?: string;
modeData?: Mode;
id?: string;
}
export interface ThemeData {
style: string;
styleSheet: string;
baseUrl: string;
script: string;
templates: { [id: string]: string }; // not supported rn
msgTemplates: { [id: string]: string };
}
export interface Mode {
name: string;
displayName: string;
color: string;
hidden?: boolean;
isChannel?: boolean;
isGlobal?: boolean;
}
enum ChatHideStates {
ShowWhenActive = 0,
AlwaysShow = 1,
AlwaysHide = 2,
}
const defaultMode: Mode = {
name: 'all',
displayName: 'All',
color: '#fff'
};
const globalMode: Mode = {
name: '_global',
displayName: 'All',
color: '#fff',
isGlobal: true,
hidden: true
};
export default Vue.extend({
template: "#app_template",
name: "app",
components: {
Suggestions,
MessageV
},
data() {
return {
style: CONFIG.style,
showInput: false,
showWindow: false,
showHideState: false,
hideState: ChatHideStates.ShowWhenActive,
backingSuggestions: [] as Suggestion[],
removedSuggestions: [] as string[],
templates: { ...CONFIG.templates } as { [ key: string ]: string },
message: "",
messages: [] as Message[],
oldMessages: [] as string[],
oldMessagesIndex: -1,
tplBackups: [] as unknown as [ HTMLElement, string ][],
msgTplBackups: [] as unknown as [ string, string ][],
focusTimer: 0,
showWindowTimer: 0,
showHideStateTimer: 0,
listener: (event: MessageEvent) => {},
modes: [defaultMode, globalMode] as Mode[],
modeIdx: 0,
};
},
destroyed() {
clearInterval(this.focusTimer);
window.removeEventListener("message", this.listener);
},
mounted() {
post("http://chat/loaded", JSON.stringify({}));
this.listener = (event: MessageEvent) => {
const item: any = event.data || (<any>event).detail; //'detail' is for debugging via browsers
if (!item || !item.type) {
return;
}
const typeRef = item.type as
'ON_OPEN' | 'ON_SCREEN_STATE_CHANGE' | 'ON_MESSAGE' | 'ON_CLEAR' | 'ON_SUGGESTION_ADD' |
'ON_SUGGESTION_REMOVE' | 'ON_TEMPLATE_ADD' | 'ON_UPDATE_THEMES' | 'ON_MODE_ADD' | 'ON_MODE_REMOVE';
if (this[typeRef]) {
this[typeRef](item);
}
};
window.addEventListener("message", this.listener);
},
watch: {
messages() {
if (this.hideState !== ChatHideStates.AlwaysHide) {
if (this.showWindowTimer) {
clearTimeout(this.showWindowTimer);
}
this.showWindow = true;
this.resetShowWindowTimer();
}
const messagesObj = this.$refs.messages as HTMLDivElement;
this.$nextTick(() => {
messagesObj.scrollTop = messagesObj.scrollHeight;
});
}
},
computed: {
filteredMessages(): Message[] {
return this.messages.filter(
// show messages that are
// - (if the current mode is a channel) global, or in the current mode
// - (if the message is a channel) in the current mode
el => (el.modeData?.isChannel || this.modes[this.modeIdx].isChannel) ?
(el.mode === this.modes[this.modeIdx].name || el.modeData?.isGlobal) :
true
);
},
suggestions(): Suggestion[] {
return this.backingSuggestions.filter(
el => this.removedSuggestions.indexOf(el.name) <= -1
);
},
hideAnimated(): boolean {
return this.hideState !== ChatHideStates.AlwaysHide;
},
modeIdxGet(): number {
return (this.modeIdx >= this.modes.length) ? (this.modes.length - 1) : this.modeIdx;
},
modePrefix(): string {
if (this.modes.length === 2) {
return ``;
}
return this.modes[this.modeIdxGet].displayName;
},
modeColor(): string {
return this.modes[this.modeIdxGet].color;
},
hideStateString(): string {
// TODO: localization
switch (this.hideState) {
case ChatHideStates.AlwaysShow:
return 'Visible';
case ChatHideStates.AlwaysHide:
return 'Hidden';
case ChatHideStates.ShowWhenActive:
return 'When active';
}
}
},
methods: {
ON_SCREEN_STATE_CHANGE({ hideState, fromUserInteraction }: { hideState: ChatHideStates, fromUserInteraction: boolean }) {
this.hideState = hideState;
if (this.hideState === ChatHideStates.AlwaysHide) {
if (!this.showInput) {
this.showWindow = false;
}
} else if (this.hideState === ChatHideStates.AlwaysShow) {
this.showWindow = true;
if (this.showWindowTimer) {
clearTimeout(this.showWindowTimer);
}
} else {
this.resetShowWindowTimer();
}
if (fromUserInteraction) {
this.showHideState = true;
if (this.showHideStateTimer) {
clearTimeout(this.showHideStateTimer);
}
this.showHideStateTimer = window.setTimeout(() => {
this.showHideState = false;
}, 1500);
}
},
ON_OPEN() {
this.showInput = true;
this.showWindow = true;
if (this.showWindowTimer) {
clearTimeout(this.showWindowTimer);
}
this.focusTimer = window.setInterval(() => {
if (this.$refs.input) {
(this.$refs.input as HTMLInputElement).focus();
} else {
clearInterval(this.focusTimer);
}
}, 100);
},
ON_MESSAGE({ message }: { message: Message }) {
message.id = `${new Date().getTime()}${Math.random()}`;
message.modeData = this.modes.find(mode => mode.name === message.mode);
this.messages.push(message);
},
ON_CLEAR() {
this.messages = [];
this.oldMessages = [];
this.oldMessagesIndex = -1;
},
ON_SUGGESTION_ADD({ suggestion }: { suggestion: Suggestion }) {
this.removedSuggestions = this.removedSuggestions.filter(a => a !== suggestion.name);
const duplicateSuggestion = this.backingSuggestions.find(
a => a.name == suggestion.name
);
if (duplicateSuggestion) {
if (suggestion.help || suggestion.params) {
duplicateSuggestion.help = suggestion.help || "";
duplicateSuggestion.params = suggestion.params || [];
}
return;
}
if (!suggestion.params) {
suggestion.params = []; //TODO Move somewhere else
}
this.backingSuggestions.push(suggestion);
},
ON_SUGGESTION_REMOVE({ name }: { name: string }) {
if (this.removedSuggestions.indexOf(name) <= -1) {
this.removedSuggestions.push(name);
}
},
ON_MODE_ADD({ mode }: { mode: Mode }) {
this.modes = [
...this.modes.filter(a => a.name !== mode.name),
mode
];
},
ON_MODE_REMOVE({ name }: { name: string }) {
this.modes = this.modes.filter(a => a.name !== name);
if (this.modes.length === 0) {
this.modes = [defaultMode];
}
},
ON_TEMPLATE_ADD({ template }: { template: { id: string, html: string }}) {
if (this.templates[template.id]) {
this.warn(`Tried to add duplicate template '${template.id}'`);
} else {
this.templates[template.id] = template.html;
}
},
ON_UPDATE_THEMES({ themes }: { themes: { [key: string]: ThemeData } }) {
this.removeThemes();
this.setThemes(themes);
},
removeThemes() {
for (let i = 0; i < document.styleSheets.length; i++) {
const styleSheet = document.styleSheets[i];
const node = styleSheet.ownerNode as Element;
if (node.getAttribute("data-theme")) {
node.parentNode?.removeChild(node);
}
}
this.tplBackups.reverse();
for (const [elem, oldData] of this.tplBackups) {
elem.innerText = oldData;
}
this.tplBackups = [];
this.msgTplBackups.reverse();
for (const [id, oldData] of this.msgTplBackups) {
this.templates[id] = oldData;
}
this.msgTplBackups = [];
},
setThemes(themes: { [key: string]: ThemeData }) {
for (const [id, data] of Object.entries(themes)) {
if (data.style) {
const style = document.createElement("style");
style.type = "text/css";
style.setAttribute("data-theme", id);
style.appendChild(document.createTextNode(data.style));
document.head.appendChild(style);
}
if (data.styleSheet) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.type = "text/css";
link.href = data.baseUrl + data.styleSheet;
link.setAttribute("data-theme", id);
document.head.appendChild(link);
}
if (data.templates) {
for (const [tplId, tpl] of Object.entries(data.templates)) {
const elem = document.getElementById(tplId);
if (elem) {
this.tplBackups.push([elem, elem.innerText]);
elem.innerText = tpl;
}
}
}
if (data.script) {
const script = document.createElement("script");
script.type = "text/javascript";
script.src = data.baseUrl + data.script;
document.head.appendChild(script);
}
if (data.msgTemplates) {
for (const [tplId, tpl] of Object.entries(data.msgTemplates)) {
this.msgTplBackups.push([tplId, this.templates[tplId]]);
this.templates[tplId] = tpl;
}
}
}
},
warn(msg: string) {
this.messages.push({
args: [msg],
template: "^3<b>CHAT-WARN</b>: ^0{0}"
});
},
clearShowWindowTimer() {
clearTimeout(this.showWindowTimer);
},
resetShowWindowTimer() {
this.clearShowWindowTimer();
this.showWindowTimer = window.setTimeout(() => {
if (this.hideState !== ChatHideStates.AlwaysShow && !this.showInput) {
this.showWindow = false;
}
}, CONFIG.fadeTimeout);
},
keyUp() {
this.resize();
},
keyDown(e: KeyboardEvent) {
if (e.which === 38 || e.which === 40) {
e.preventDefault();
this.moveOldMessageIndex(e.which === 38);
} else if (e.which == 33) {
var buf = document.getElementsByClassName("chat-messages")[0];
buf.scrollTop = buf.scrollTop - 100;
} else if (e.which == 34) {
var buf = document.getElementsByClassName("chat-messages")[0];
buf.scrollTop = buf.scrollTop + 100;
} else if (e.which === 9) { // tab
if (e.shiftKey || e.altKey) {
do {
--this.modeIdx;
if (this.modeIdx < 0) {
this.modeIdx = this.modes.length - 1;
}
} while (this.modes[this.modeIdx].hidden);
} else {
do {
this.modeIdx = (this.modeIdx + 1) % this.modes.length;
} while (this.modes[this.modeIdx].hidden);
}
const buf = document.getElementsByClassName('chat-messages')[0];
setTimeout(() => buf.scrollTop = buf.scrollHeight, 0);
}
this.resize();
},
moveOldMessageIndex(up: boolean) {
if (up && this.oldMessages.length > this.oldMessagesIndex + 1) {
this.oldMessagesIndex += 1;
this.message = this.oldMessages[this.oldMessagesIndex];
} else if (!up && this.oldMessagesIndex - 1 >= 0) {
this.oldMessagesIndex -= 1;
this.message = this.oldMessages[this.oldMessagesIndex];
} else if (!up && this.oldMessagesIndex - 1 === -1) {
this.oldMessagesIndex = -1;
this.message = "";
}
},
resize() {
const input = this.$refs.input as HTMLInputElement;
// scrollHeight includes padding, but content-box excludes padding
// remove padding before setting height on the element
const style = getComputedStyle(input);
const paddingRemove = parseFloat(style.paddingBottom) + parseFloat(style.paddingTop);
input.style.height = "5px";
input.style.height = `${input.scrollHeight - paddingRemove}px`;
},
send() {
if (this.message !== "") {
post(
"http://chat/chatResult",
JSON.stringify({
message: this.message,
mode: this.modes[this.modeIdxGet].name
})
);
this.oldMessages.unshift(this.message);
this.oldMessagesIndex = -1;
this.hideInput();
} else {
this.hideInput(true);
}
},
hideInput(canceled = false) {
setTimeout(() => {
const input = this.$refs.input as HTMLInputElement;
delete input.style.height;
}, 50);
if (canceled) {
post("http://chat/chatResult", JSON.stringify({ canceled }));
}
this.message = "";
this.showInput = false;
clearInterval(this.focusTimer);
if (this.hideState !== ChatHideStates.AlwaysHide) {
this.resetShowWindowTimer();
} else {
this.showWindow = false;
}
}
}
});

View File

@@ -0,0 +1,44 @@
<template>
<div id="app">
<div class="chat-window" :style="this.style" :class="{
'animated': !showWindow && hideAnimated,
'hidden': !showWindow
}">
<div class="chat-messages" ref="messages">
<message v-for="msg in filteredMessages"
:templates="templates"
:multiline="msg.multiline"
:args="msg.args"
:params="msg.params"
:color="msg.color"
:template="msg.template"
:template-id="msg.templateId"
:key="msg.id">
</message>
</div>
</div>
<div class="chat-input">
<div v-show="showInput" class="input">
<span class="prefix" :class="{ any: modes.length > 1 }" :style="{ color: modeColor }">{{modePrefix}}</span>
<textarea v-model="message"
ref="input"
type="text"
autofocus
spellcheck="false"
rows="1"
@keyup.esc="hideInput"
@keyup="keyUp"
@keydown="keyDown"
@keypress.enter.prevent="send">
</textarea>
</div>
<suggestions :message="message" :suggestions="suggestions">
</suggestions>
<div class="chat-hide-state" v-show="showHideState">
{{hideStateString}}
</div>
</div>
</div>
</template>
<script lang="ts" src="./App.ts"></script>

View File

@@ -0,0 +1,102 @@
import CONFIG from './config';
import Vue, { PropType } from 'vue';
export default Vue.component('message', {
data() {
return {};
},
computed: {
textEscaped(): string {
let s = this.template ? this.template : this.templates[this.templateId];
//This hack is required to preserve backwards compatability
if (!this.template && this.templateId == CONFIG.defaultTemplateId
&& this.args.length == 1) {
s = this.templates[CONFIG.defaultAltTemplateId] //Swap out default template :/
}
s = s.replace(`@default`, this.templates[this.templateId]);
s = s.replace(/{(\d+)}/g, (match, number) => {
const argEscaped = this.args[number] != undefined ? this.escape(this.args[number]) : match;
if (number == 0 && this.color) {
//color is deprecated, use templates or ^1 etc.
return this.colorizeOld(argEscaped);
}
return argEscaped;
});
// format variant args
s = s.replace(/\{\{([a-zA-Z0-9_\-]+?)\}\}/g, (match, id) => {
const argEscaped = this.params[id] != undefined ? this.escape(this.params[id]) : match;
return argEscaped;
});
return this.colorize(s);
},
},
methods: {
colorizeOld(str: string): string {
return `<span style="color: rgb(${this.color[0]}, ${this.color[1]}, ${this.color[2]})">${str}</span>`
},
colorize(str: string): string {
let s = "<span>" + colorTrans(str) + "</span>";
const styleDict: {[ key: string ]: string} = {
'*': 'font-weight: bold;',
'_': 'text-decoration: underline;',
'~': 'text-decoration: line-through;',
'=': 'text-decoration: underline line-through;',
'r': 'text-decoration: none;font-weight: normal;',
};
const styleRegex = /\^(\_|\*|\=|\~|\/|r)(.*?)(?=$|\^r|<\/em>)/;
while (s.match(styleRegex)) { //Any better solution would be appreciated :P
s = s.replace(styleRegex, (str, style, inner) => `<em style="${styleDict[style]}">${inner}</em>`)
}
return s.replace(/<span[^>]*><\/span[^>]*>/g, '');
function colorTrans(str: string) {
return str
.replace(/\^([0-9])/g, (str, color) => `</span><span class="color-${color}">`)
.replace(/\^#([0-9A-F]{3,6})/gi, (str, color) => `</span><span class="color" style="color: #${color}">`)
.replace(/~([a-z])~/g, (str, color) => `</span><span class="gameColor-${color}">`);
}
},
escape(unsafe: string): string {
return String(unsafe)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
},
props: {
templates: {
type: Object as PropType<{ [key: string]: string }>,
},
args: {
type: Array as PropType<string[]>,
},
params: {
type: Object as PropType<{ [ key: string]: string }>,
},
template: {
type: String,
default: null,
},
templateId: {
type: String,
default: CONFIG.defaultTemplateId,
},
multiline: {
type: Boolean,
default: false,
},
color: { //deprecated
type: Array as PropType<number[]>,
default: null,
},
},
});

View File

@@ -0,0 +1,7 @@
<template>
<div class="msg" :class="{ multiline }">
<span v-html="textEscaped"></span>
</div>
</template>
<script lang="ts" src="./Message.ts"></script>

View File

@@ -0,0 +1,63 @@
import CONFIG from './config';
import Vue, { PropType } from 'vue';
export interface Suggestion {
name: string;
help: string;
params: string[];
disabled: boolean;
}
export default Vue.component('suggestions', {
props: {
message: {
type: String
},
suggestions: {
type: Array as PropType<Suggestion[]>
}
},
data() {
return {};
},
computed: {
currentSuggestions(): Suggestion[] {
if (this.message === '') {
return [];
}
const currentSuggestions = this.suggestions.filter((s) => {
if (!s.name.startsWith(this.message)) {
const suggestionSplitted = s.name.split(' ');
const messageSplitted = this.message.split(' ');
for (let i = 0; i < messageSplitted.length; i += 1) {
if (i >= suggestionSplitted.length) {
return i < suggestionSplitted.length + s.params.length;
}
if (suggestionSplitted[i] !== messageSplitted[i]) {
return false;
}
}
}
return true;
}).slice(0, CONFIG.suggestionLimit);
currentSuggestions.forEach((s) => {
// eslint-disable-next-line no-param-reassign
s.disabled = !s.name.startsWith(this.message);
s.params.forEach((p, index) => {
const wType = (index === s.params.length - 1) ? '.' : '\\S';
const regex = new RegExp(`${s.name} (?:\\w+ ){${index}}(?:${wType}*)$`, 'g');
// eslint-disable-next-line no-param-reassign
// @ts-ignore
p.disabled = this.message.match(regex) == null;
});
});
return currentSuggestions;
},
},
methods: {},
});

View File

@@ -0,0 +1,29 @@
<template>
<div class="suggestions-wrap" v-show="currentSuggestions.length > 0">
<ul class="suggestions">
<li class="suggestion" v-for="s in currentSuggestions" :key="s.name">
<p>
<span :class="{ 'disabled': s.disabled }">
{{s.name}}
</span>
<span class="param"
v-for="p in s.params"
:class="{ 'disabled': p.disabled }"
:key="p.name">
[{{p.name}}]
</span>
</p>
<small class="help">
<template v-if="!s.disabled">
{{s.help}}
</template>
<template v-for="p in s.params" v-if="!p.disabled">
{{p.help}}
</template>
</small>
</li>
</ul>
</div>
</template>
<script lang="ts" src="./Suggestions.ts"></script>

View File

@@ -0,0 +1,17 @@
export default {
defaultTemplateId: 'default', //This is the default template for 2 args1
defaultAltTemplateId: 'defaultAlt', //This one for 1 arg
templates: { //You can add static templates here
'default': '<b>{0}</b>: {1}',
'defaultAlt': '{0}',
'print': '<pre>{0}</pre>',
'example:important': '<h1>^2{0}</h1>'
},
fadeTimeout: 7000,
suggestionLimit: 5,
style: {
background: 'rgba(52, 73, 94, 0.7)',
width: '38vw',
height: '22%',
}
};

View File

@@ -0,0 +1,160 @@
.color-0{color: #ffffff;}
.color-1{color: #ff4444;}
.color-2{color: #99cc00;}
.color-3{color: #ffbb33;}
.color-4{color: #0099cc;}
.color-5{color: #33b5e5;}
.color-6{color: #aa66cc;}
.color-8{color: #cc0000;}
.color-9{color: #cc0068;}
.gameColor-w{color: #ffffff;}
.gameColor-r{color: #ff4444;}
.gameColor-g{color: #99cc00;}
.gameColor-y{color: #ffbb33;}
.gameColor-b{color: #33b5e5;}
/* todo: more game colors */
* {
font-family: 'Lato', sans-serif;
margin: 0;
padding: 0;
}
.no-grow {
flex-grow: 0;
}
em {
font-style: normal;
}
#app {
font-family: 'Lato', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: white;
}
.chat-window {
position: absolute;
top: 1.5%;
left: 0.8%;
width: 38%;
height: 22%;
max-width: 1000px;
background-color: rgba(52, 73, 94, 0.7);
-webkit-animation-duration: 2s;
}
.chat-messages {
position: relative;
height: 95%;
font-size: 1.8vh;
margin: 1%;
overflow-x: hidden;
overflow-y: hidden;
}
.chat-input {
font-size: 1.65vh;
position: absolute;
top: 23.8%;
left: 0.8%;
width: 38%;
max-width: 1000px;
box-sizing: border-box;
}
.chat-input > div.input {
position: relative;
display: flex;
align-items: stretch;
width: 100%;
background-color: rgba(44, 62, 80, 1.0);
}
.chat-hide-state {
text-transform: uppercase;
margin-left: 0.05vw;
font-size: 1.65vh;
}
.prefix {
font-size: 1.8vh;
/*position: absolute;
top: 0%;*/
height: 100%;
vertical-align: middle;
line-height: calc(1vh + 1vh + 1.85vh);
padding-left: 0.5vh;
text-transform: uppercase;
font-weight: bold;
display: inline-block;
}
textarea {
font-size: 1.65vh;
line-height: 1.85vh;
display: block;
box-sizing: content-box;
padding: 1vh;
padding-left: 0.5vh;
color: white;
border-width: 0;
height: 3.15%;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
background-color: transparent;
}
textarea:focus, input:focus {
outline: none;
}
.msg {
margin-bottom: 0.28%;
}
.multiline {
margin-left: 4%;
text-indent: -1.2rem;
white-space: pre-line;
}
.suggestions {
list-style-type: none;
padding: 0.5%;
padding-left: 1.4%;
font-size: 1.65vh;
box-sizing: border-box;
color: white;
background-color: rgba(44, 62, 80, 1.0);
width: 100%;
}
.help {
color: #b0bbbd;
}
.disabled {
color: #b0bbbd;
}
.suggestion {
margin-bottom: 0.5%;
}
.hidden {
opacity: 0;
}
.hidden.animated {
transition: opacity 1s;
}

View File

@@ -0,0 +1,4 @@
declare module '*.vue' {
import Vue from 'vue'
export default Vue
}

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<link href="/html/vendor/latofonts.css" rel="stylesheet">
<link href="/html/vendor/flexboxgrid.6.3.1.min.css" rel="stylesheet"></link>
<link href="/html/vendor/animate.3.5.2.min.css" rel="stylesheet"></link>
<link href="index.css" rel="stylesheet"></link>
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import Vue from 'vue';
import App from './App.vue';
const instance = new Vue({
el: '#app',
render: h => h(App),
});

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"outDir": "./",
"module": "es6",
"strict": true,
"moduleResolution": "node",
"target": "es6",
"allowJs": true,
"lib": [
"es2017",
"dom"
]
},
"include": [
"./**/*"
],
"exclude": []
}

View File

@@ -0,0 +1,31 @@
export function post(url: string, data: any) {
var request = new XMLHttpRequest();
request.open('POST', url, true);
request.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
request.send(data);
}
function emulate(type: string, detail = {}) {
const detailRef = {
type,
...detail
};
window.dispatchEvent(new CustomEvent('message', {
detail: detailRef
}));
}
(window as any)['emulate'] = emulate;
(window as any)['demo'] = () => {
emulate('ON_MESSAGE', {
message: {
args: [ 'me', 'hello!' ]
}
})
emulate('ON_SCREEN_STATE_CHANGE', {
shouldHide: false
});
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,48 @@
/* latin-ext */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 300;
src: local('Lato Light'), local('Lato-Light'), url(fonts/LatoLight.woff2);
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 300;
src: local('Lato Light'), local('Lato-Light'), url(fonts/LatoLight2.woff2);
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215;
}
/* latin-ext */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 400;
src: local('Lato Regular'), local('Lato-Regular'), url(fonts/LatoRegular.woff2);
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 400;
src: local('Lato Regular'), local('Lato-Regular'), url(fonts/LatoRegular2.woff2);
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215;
}
/* latin-ext */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
src: local('Lato Bold'), local('Lato-Bold'), url(fonts/LatoBold.woff2);
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
src: local('Lato Bold'), local('Lato-Bold'), url(fonts/LatoBold2.woff2);
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215;
}

View File

@@ -0,0 +1,24 @@
{
"name": "chat",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"@types/vue": "^2.0.0",
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.4.2",
"html-webpack-inline-source-plugin": "^0.0.10",
"html-webpack-plugin": "^3.2.0",
"ts-loader": "^6.2.1",
"typescript": "^3.8.3",
"vue": "^2.6.11",
"vue-loader": "^15.9.0",
"vue-template-compiler": "^2.6.11",
"webpack": "4"
},
"devDependencies": {
"command-line-args": "^5.1.1",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.10.3"
}
}

View File

@@ -0,0 +1,293 @@
RegisterServerEvent('chat:init')
RegisterServerEvent('chat:addTemplate')
RegisterServerEvent('chat:addMessage')
RegisterServerEvent('chat:addSuggestion')
RegisterServerEvent('chat:removeSuggestion')
RegisterServerEvent('_chat:messageEntered')
RegisterServerEvent('chat:clear')
RegisterServerEvent('__cfx_internal:commandFallback')
-- this is a built-in event, but somehow needs to be registered
RegisterNetEvent('playerJoining')
exports('addMessage', function(target, message)
if not message then
message = target
target = -1
end
if not target or not message then return end
TriggerClientEvent('chat:addMessage', target, message)
end)
local hooks = {}
local hookIdx = 1
exports('registerMessageHook', function(hook)
local resource = GetInvokingResource()
hooks[hookIdx + 1] = {
fn = hook,
resource = resource
}
hookIdx = hookIdx + 1
end)
local modes = {}
local function getMatchingPlayers(seObject)
local players = GetPlayers()
local retval = {}
for _, v in ipairs(players) do
if IsPlayerAceAllowed(v, seObject) then
retval[#retval + 1] = v
end
end
return retval
end
exports('registerMode', function(modeData)
if not modeData.name or not modeData.displayName or not modeData.cb then
return false
end
local resource = GetInvokingResource()
modes[modeData.name] = modeData
modes[modeData.name].resource = resource
local clObj = {
name = modeData.name,
displayName = modeData.displayName,
color = modeData.color or '#fff',
isChannel = modeData.isChannel,
isGlobal = modeData.isGlobal,
}
if not modeData.seObject then
TriggerClientEvent('chat:addMode', -1, clObj)
else
for _, v in ipairs(getMatchingPlayers(modeData.seObject)) do
TriggerClientEvent('chat:addMode', v, clObj)
end
end
return true
end)
local function unregisterHooks(resource)
local toRemove = {}
for k, v in pairs(hooks) do
if v.resource == resource then
table.insert(toRemove, k)
end
end
for _, v in ipairs(toRemove) do
hooks[v] = nil
end
toRemove = {}
for k, v in pairs(modes) do
if v.resource == resource then
table.insert(toRemove, k)
end
end
for _, v in ipairs(toRemove) do
TriggerClientEvent('chat:removeMode', -1, {
name = v
})
modes[v] = nil
end
end
local function routeMessage(source, author, message, mode, fromConsole)
if source >= 1 then
author = GetPlayerName(source)
end
local outMessage = {
color = { 255, 255, 255 },
multiline = true,
args = { message },
mode = mode
}
if author ~= "" then
outMessage.args = { author, message }
end
if mode and modes[mode] then
local modeData = modes[mode]
if modeData.seObject and not IsPlayerAceAllowed(source, modeData.seObject) then
return
end
end
local messageCanceled = false
local routingTarget = -1
local hookRef = {
updateMessage = function(t)
-- shallow merge
for k, v in pairs(t) do
if k == 'template' then
outMessage['template'] = v:gsub('%{%}', outMessage['template'] or '@default')
elseif k == 'params' then
if not outMessage.params then
outMessage.params = {}
end
for pk, pv in pairs(v) do
outMessage.params[pk] = pv
end
else
outMessage[k] = v
end
end
end,
cancel = function()
messageCanceled = true
end,
setSeObject = function(object)
routingTarget = getMatchingPlayers(object)
end,
setRouting = function(target)
routingTarget = target
end
}
for _, hook in pairs(hooks) do
if hook.fn then
hook.fn(source, outMessage, hookRef)
end
end
if modes[mode] then
local m = modes[mode]
m.cb(source, outMessage, hookRef)
end
if messageCanceled then
return
end
TriggerEvent('chatMessage', source, #outMessage.args > 1 and outMessage.args[1] or '', outMessage.args[#outMessage.args])
if not WasEventCanceled() then
if type(routingTarget) ~= 'table' then
TriggerClientEvent('chat:addMessage', routingTarget, outMessage)
else
for _, id in ipairs(routingTarget) do
TriggerClientEvent('chat:addMessage', id, outMessage)
end
end
end
if not fromConsole then
print(author .. '^7' .. (modes[mode] and (' (' .. modes[mode].displayName .. ')') or '') .. ': ' .. message .. '^7')
end
end
AddEventHandler('_chat:messageEntered', function(author, color, message, mode)
if not message or not author then
return
end
local source = source
routeMessage(source, author, message, mode)
end)
AddEventHandler('__cfx_internal:commandFallback', function(command)
local name = GetPlayerName(source)
-- route the message as if it were a /command
routeMessage(source, name, '/' .. command, nil, true)
CancelEvent()
end)
-- player join messages
AddEventHandler('playerJoining', function()
if GetConvarInt('chat_showJoins', 1) == 0 then
return
end
TriggerClientEvent('chatMessage', -1, '', { 255, 255, 255 }, '^2* ' .. GetPlayerName(source) .. ' joined.')
end)
AddEventHandler('playerDropped', function(reason)
if GetConvarInt('chat_showQuits', 1) == 0 then
return
end
TriggerClientEvent('chatMessage', -1, '', { 255, 255, 255 }, '^2* ' .. GetPlayerName(source) ..' left (' .. reason .. ')')
end)
RegisterCommand('say', function(source, args, rawCommand)
routeMessage(source, (source == 0) and 'console' or GetPlayerName(source), rawCommand:sub(5), nil, true)
end)
-- command suggestions for clients
local function refreshCommands(player)
if GetRegisteredCommands then
local registeredCommands = GetRegisteredCommands()
local suggestions = {}
for _, command in ipairs(registeredCommands) do
if IsPlayerAceAllowed(player, ('command.%s'):format(command.name)) then
table.insert(suggestions, {
name = '/' .. command.name,
help = ''
})
end
end
TriggerClientEvent('chat:addSuggestions', player, suggestions)
end
end
AddEventHandler('chat:init', function()
local source = source
refreshCommands(source)
for _, modeData in pairs(modes) do
local clObj = {
name = modeData.name,
displayName = modeData.displayName,
color = modeData.color or '#fff',
isChannel = modeData.isChannel,
isGlobal = modeData.isGlobal,
}
if not modeData.seObject or IsPlayerAceAllowed(source, modeData.seObject) then
TriggerClientEvent('chat:addMode', source, clObj)
end
end
end)
AddEventHandler('onServerResourceStart', function(resName)
Wait(500)
for _, player in ipairs(GetPlayers()) do
refreshCommands(player)
end
end)
AddEventHandler('onResourceStop', function(resName)
unregisterHooks(resName)
end)

View File

@@ -0,0 +1,45 @@
const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
mode: 'production',
entry: './html/main.ts',
module: {
rules: [
{
test: /\.ts$/,
loader: 'ts-loader',
exclude: /node_modules/,
options: {
appendTsSuffixTo: [/\.vue$/],
}
},
{
test: /\.vue$/,
loader: 'vue-loader'
},
]
},
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
inlineSource: '.(js|css)$',
template: './html/index.html',
filename: 'ui.html'
}),
new HtmlWebpackInlineSourcePlugin(),
new CopyPlugin([
{ from: 'html/index.css', to: 'index.css' }
]),
],
resolve: {
extensions: [ '.ts', '.js' ]
},
output: {
filename: 'chat.js',
path: __dirname + '/dist/'
},
//devtool: 'inline-source-map'
};

File diff suppressed because it is too large Load Diff