diff --git a/shared/js/ui/frames/log/DispatcherFocus.ts b/shared/js/ui/frames/log/DispatcherFocus.ts new file mode 100644 index 00000000..51faf01e --- /dev/null +++ b/shared/js/ui/frames/log/DispatcherFocus.ts @@ -0,0 +1,13 @@ +import {EventType} from "tc-shared/ui/frames/log/Definitions"; +import {Settings, settings} from "tc-shared/settings"; + +const focusDefaultStatus = {}; +focusDefaultStatus[EventType.CLIENT_POKE_RECEIVED] = true; + +export function requestWindowFocus() { + window.focus(); +} + +export function isFocusRequestEnabled(type: EventType) { + return settings.global(Settings.FN_EVENTS_FOCUS_ENABLED(type), focusDefaultStatus[type as any] || false); +} \ No newline at end of file diff --git a/shared/js/ui/frames/log/DispatcherNotifications.ts b/shared/js/ui/frames/log/DispatcherNotifications.ts index 9323dee3..e1a57b9d 100644 --- a/shared/js/ui/frames/log/DispatcherNotifications.ts +++ b/shared/js/ui/frames/log/DispatcherNotifications.ts @@ -33,8 +33,6 @@ function registerDispatcher(key: T, builder: Dispatche } export function findNotificationDispatcher(type: T) : DispatcherLog { - if(!isNotificationEnabled(type as any)) - return undefined; return dispatchers[type]; } diff --git a/shared/js/ui/frames/log/ServerEventLog.tsx b/shared/js/ui/frames/log/ServerEventLog.tsx index 8c9a346f..89a5ade8 100644 --- a/shared/js/ui/frames/log/ServerEventLog.tsx +++ b/shared/js/ui/frames/log/ServerEventLog.tsx @@ -4,7 +4,9 @@ import {LogMessage, ServerLogUIEvents, TypeInfo} from "tc-shared/ui/frames/log/D import {Registry} from "tc-shared/events"; import * as ReactDOM from "react-dom"; import {ServerLogRenderer} from "tc-shared/ui/frames/log/Renderer"; -import {findNotificationDispatcher} from "tc-shared/ui/frames/log/DispatcherNotifications"; +import {findNotificationDispatcher, isNotificationEnabled} from "tc-shared/ui/frames/log/DispatcherNotifications"; +import {Settings, settings} from "tc-shared/settings"; +import {isFocusRequestEnabled, requestWindowFocus} from "tc-shared/ui/frames/log/DispatcherFocus"; const cssStyle = require("./Renderer.scss"); @@ -39,14 +41,22 @@ export class ServerEventLog { uniqueId: "log-" + Date.now() + "-" + (++uniqueLogEventId) }; - this.eventLog.push(event); - while(this.eventLog.length > this.maxHistoryLength) - this.eventLog.pop_front(); + if(settings.global(Settings.FN_EVENTS_LOG_ENABLED(type), true)) { + this.eventLog.push(event); + while(this.eventLog.length > this.maxHistoryLength) + this.eventLog.pop_front(); - this.uiEvents.fire_async("notify_log_add", { event: event }); + this.uiEvents.fire_async("notify_log_add", { event: event }); + } - const notification = findNotificationDispatcher(type); - if(notification) notification(data, this.connection.handlerId, type); + if(isNotificationEnabled(type as any)) { + const notification = findNotificationDispatcher(type); + if(notification) notification(data, this.connection.handlerId, type); + } + + if(isFocusRequestEnabled(type as any)) { + requestWindowFocus(); + } } getHTMLTag() { diff --git a/shared/js/ui/modal/ModalSettings.tsx b/shared/js/ui/modal/ModalSettings.tsx index 220c4326..c37caf9d 100644 --- a/shared/js/ui/modal/ModalSettings.tsx +++ b/shared/js/ui/modal/ModalSettings.tsx @@ -31,6 +31,7 @@ import * as arecorder from "tc-backend/audio/recorder"; import {KeyMapSettings} from "tc-shared/ui/modal/settings/Keymap"; import * as React from "react"; import * as ReactDOM from "react-dom"; +import {NotificationSettings} from "tc-shared/ui/modal/settings/Notifications"; export function spawnSettingsModal(default_page?: string) : Modal { let modal: Modal; @@ -73,6 +74,7 @@ export function spawnSettingsModal(default_page?: string) : Modal { settings_general_language(modal.htmlTag.find(".right .container.general-language"), modal); settings_general_chat(modal.htmlTag.find(".right .container.general-chat"), modal); settings_general_keymap(modal.htmlTag.find(".right .container.general-keymap"), modal); + settings_general_notifications(modal.htmlTag.find(".right .container.general-notifications"), modal); settings_audio_microphone(modal.htmlTag.find(".right .container.audio-microphone"), modal); settings_audio_speaker(modal.htmlTag.find(".right .container.audio-speaker"), modal); settings_audio_sounds(modal.htmlTag.find(".right .container.audio-sounds"), modal); @@ -350,6 +352,12 @@ function settings_general_keymap(container: JQuery, modal: Modal) { modal.close_listener.push(() => ReactDOM.unmountComponentAtNode(container[0])); } +function settings_general_notifications(container: JQuery, modal: Modal) { + const entry = ; + ReactDOM.render(entry, container[0]); + modal.close_listener.push(() => ReactDOM.unmountComponentAtNode(container[0])); +} + function settings_general_chat(container: JQuery, modal: Modal) { /* timestamp format */ { diff --git a/shared/js/ui/modal/settings/Keymap.tsx b/shared/js/ui/modal/settings/Keymap.tsx index c44f0aed..0917e39f 100644 --- a/shared/js/ui/modal/settings/Keymap.tsx +++ b/shared/js/ui/modal/settings/Keymap.tsx @@ -12,6 +12,7 @@ import {createErrorModal} from "tc-shared/ui/elements/Modal"; import {tra} from "tc-shared/i18n/localize"; import * as keycontrol from "./../../../KeyControl"; import {MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu"; +import {useRef} from "react"; const cssStyle = require("./Keymap.scss"); @@ -292,30 +293,25 @@ class ButtonBar extends ReactComponentBase<{ event_registry: Registry { - private readonly event_registry: Registry; +export const KeyMapSettings = () => { + const events = useRef>(undefined); - constructor(props) { - super(props); - - this.event_registry = new Registry(); - initialize_timeouts(this.event_registry); - initialize_controller(this.event_registry); + if(events.current === undefined) { + events.current = new Registry(); + initialize_timeouts(events.current); + initialize_controller(events.current); } - render() { - //TODO: May refresh button? - return [ -
- -
, -
- - -
- ]; - } -} + return (<> +
+ +
+
+ + +
+ ); +}; function initialize_timeouts(event_registry: Registry) { /* query */ diff --git a/shared/js/ui/modal/settings/Notifications.scss b/shared/js/ui/modal/settings/Notifications.scss new file mode 100644 index 00000000..6d675ef6 --- /dev/null +++ b/shared/js/ui/modal/settings/Notifications.scss @@ -0,0 +1,267 @@ +@import "../../../../css/static/mixin.scss"; +@import "../../../../css/static/properties.scss"; + +.header { + height: 3em; + flex-grow: 0; + flex-shrink: 0; + display: flex; + flex-direction: row; + justify-content: stretch; + padding-bottom: 0.5em; + + a { + flex-grow: 1; + flex-shrink: 1; + align-self: flex-end; + font-weight: bold; + color: #e0e0e0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.body { + display: flex; + flex-direction: column; + justify-content: stretch; + + height: 2em; + flex-grow: 1; +} + +.containerTable { + flex-grow: 1; + flex-shrink: 1; + + display: flex; + flex-direction: column; + justify-content: stretch; + + min-height: 6em; + height: 100%; + + .tableHeader { + flex-grow: 0; + flex-shrink: 0; + + margin-right: .5em; /* scroll bar width */ + + a { + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .column { + background-color: var(--modal-permissions-table-header); + color: var(--modal-permissions-table-header-text); + font-weight: bold; + } + + .tooltip { + display: flex; + margin-left: .5em; + + width: 1.1em; + height: 1.1em; + + img { + height: 100%; + width: 100%; + } + } + } + + .tableBody { + flex-shrink: 1; + + height: 100%; + min-height: 2em; + + overflow-x: hidden; + overflow-y: scroll; + + position: relative; + + @include chat-scrollbar-vertical(); + + .overlay { + position: absolute; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; + justify-content: flex-start; + + z-index: 1; + background-color: var(--modal-permission-right); + + padding-top: 2em; + + a { + text-align: center; + font-size: 1.6em; + + color: var(--modal-permission-loading); + } + + &.hidden { + opacity: 0; + pointer-events: none; + } + + &.error { + a { + color: var(--modal-permission-error); + } + } + } + } + + .tableEntry { + flex-shrink: 0; + flex-grow: 0; + + display: flex; + flex-direction: row; + justify-content: stretch; + + line-height: 1.8em; + + color: var(--modal-permissions-table-entry-active-text); + background-color: var(--modal-permissions-table-row-odd); + + &.groupEntry { + color: var(--modal-permissions-table-entry-group-text); + font-weight: bold; + + :global(.arrow) { + cursor: pointer; + border-color: var(--modal-permissions-table-entry-active-text); + } + + a { + margin-left: .5em; + } + } + + .column { + display: flex; + flex-direction: row; + justify-content: flex-start; + + padding-left: 1em; + + border: none; + border-right: 1px solid var(--modal-permissions-table-border); + + &.columnKey { + flex-grow: 1; + flex-shrink: 1; + + min-width: 5em; + } + + &.columnLog, &.columnNotification, &.columnFocus { + flex-grow: 0; + flex-shrink: 0; + + padding: 0; + justify-content: center; + width: 5em; + } + + &:last-of-type { + border-right: none; + } + + > * { + align-self: center; + } + } + + &:nth-of-type(2n) { + background-color: var(--modal-permissions-table-row-even); + } + + &:hover { + background-color: var(--modal-permissions-table-row-hover); + } + + border-bottom: 1px solid var(--modal-permissions-table-border); + } +} + +.containerFilter { + flex-shrink: 0; + flex-grow: 0; + + display: flex; + flex-direction: row; + justify-content: center; + + margin-left: 1em; + margin-right: 1em; + + .input { + flex-grow: 1; + flex-shrink: 1; + + min-width: 4em; + } +} + +/* +.row { + position: absolute; + + left: 0; + right: 0; + + color: var(--modal-permissions-table-row-text); + background-color: var(--modal-permissions-table-row-odd); + + &.even { + background-color: var(--modal-permissions-table-row-even); + } + + &:hover { + background-color: var(--modal-permissions-table-row-hover); + } + + input[type="number"] { + color: var(--modal-permissions-table-input); + + outline: none; + background: transparent; + border: none; + + height: 1.5em; + width: 5em; /* the column width minus one */ + + /* fix the column padding + padding-left: 1em; + margin-left: -.5em; /* have a bit of space on both sides + + border-bottom: 2px solid transparent; + + @include transition(border-bottom-color $button_hover_animation_time ease-in-out); + + &:not(.applying):focus { + border-bottom-color: var(--modal-permissions-table-input-focus); + } + + + &.applying { + padding-left: 0; + } + } +} + */ \ No newline at end of file diff --git a/shared/js/ui/modal/settings/Notifications.tsx b/shared/js/ui/modal/settings/Notifications.tsx new file mode 100644 index 00000000..1bc5b30b --- /dev/null +++ b/shared/js/ui/modal/settings/Notifications.tsx @@ -0,0 +1,454 @@ +import * as React from "react"; +import {useEffect, useRef, useState} from "react"; +import {Registry} from "tc-shared/events"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import {FlatInputField} from "tc-shared/ui/react-elements/InputField"; +import {EventType} from "tc-shared/ui/frames/log/Definitions"; +import { + getRegisteredNotificationDispatchers, + isNotificationEnabled +} from "tc-shared/ui/frames/log/DispatcherNotifications"; +import {Settings, settings} from "tc-shared/settings"; +import {Checkbox} from "tc-shared/ui/react-elements/Checkbox"; +import {Tooltip} from "tc-shared/ui/react-elements/Tooltip"; +import {isFocusRequestEnabled} from "tc-shared/ui/frames/log/DispatcherFocus"; + +const cssStyle = require("./Notifications.scss"); + +type NotificationState = "enabled" | "disabled" | "unavailable"; + +interface EventGroup { + key: string; + name: string; + + events?: string[]; + subgroups?: EventGroup[]; +} + +interface NotificationSettingsEvents { + action_set_filter: { filter: string }, /* will toggle a notify_event_info */ + action_toggle_group: { groupKey: string, collapsed: boolean }, + + action_set_state: { + key: string, + state: "log" | "notification" | "focus", + value: NotificationState + }, + + query_events: {}, + query_event_info: { key: string }, + + notify_events: { + groups: EventGroup[], focusEnabled: boolean + }, + notify_event_info: { + key: string; + name: string; + + focus: NotificationState; + notification: NotificationState; + log: NotificationState; + }, + notify_set_state_result: { + key: string, + state: "log" | "notification" | "focus", + value: NotificationState + } +} + +const EventTableHeader = (props: { focus: boolean }) => { + return ( +
+
+ Event +
+ {!props.focus ? undefined : +
+ Focus + ( + Draw focus to the window when the event occurs + )}> +
+ +
+
+
+ } +
+ Notify + ( + Sending out a system notification + )}> +
+ +
+
+
+
+ Log + ( + Log the event within the client server log + )}> +
+ +
+
+
+
+ ) +}; + +const EventTableEntry = React.memo((props: { events: Registry, event: string, depth: number, focusEnabled: boolean }) => { + const [ name, setName ] = useState(() => { + props.events.fire_async("query_event_info", { key: props.event }); + return undefined; + }); + const [ notificationState, setNotificationState ] = useState("unavailable"); + const [ logState, setLogState ] = useState("unavailable"); + const [ focusState, setFocusState ] = useState("unavailable"); + + const [ notificationApplying, setNotificationApplying ] = useState(false); + const [ logApplying, setLogApplying ] = useState(false); + const [ focusApplying, setFocusApplying ] = useState(false); + + props.events.reactUse("notify_event_info", event => { + if(event.key !== props.event) + return; + + setName(event.name); + setNotificationState(event.notification); + setLogState(event.log); + setFocusState(event.focus); + }); + + props.events.reactUse("action_set_state", event => { + if(event.key !== props.event) + return; + + switch (event.state) { + case "notification": + setNotificationApplying(true); + break; + + case "log": + setLogApplying(true); + break; + + case "focus": + setFocusApplying(true); + break; + } + }); + + props.events.reactUse("notify_set_state_result", event => { + if(event.key !== props.event) + return; + + switch (event.state) { + case "notification": + setNotificationApplying(false); + setNotificationState(event.value); + break; + + case "log": + setLogApplying(false); + setLogState(event.value); + break; + + case "focus": + setFocusApplying(false); + setFocusState(event.value); + break; + } + }); + + let notificationElement, logElement, focusElement; + if(notificationState === "unavailable") { + notificationElement = null; + } else { + notificationElement = ( + { + props.events.fire("action_set_state", { key: props.event, state: "notification", value: value ? "enabled" : "disabled"}); + }} disabled={notificationApplying} /> + ); + } + + if(logState === "unavailable") { + logElement = null; + } else { + logElement = ( + { + props.events.fire("action_set_state", { key: props.event, state: "log", value: value ? "enabled" : "disabled"}); + }} disabled={logApplying} /> + ); + } + + if(focusState === "unavailable") { + focusElement = null; + } else { + focusElement = ( + { + props.events.fire("action_set_state", { key: props.event, state: "focus", value: value ? "enabled" : "disabled"}); + }} disabled={focusApplying} /> + ); + } + + return ( +
+
{name || props.event}
+ {!props.focusEnabled ? undefined : +
+ {focusElement} +
+ } +
+ {notificationElement} +
+
+ {logElement} +
+
+ ); +}); + +const EventTableGroupEntry = (props: { events: Registry, group: EventGroup, depth: number, focusEnabled: boolean }) => { + const [ collapsed, setCollapsed ] = useState(false); + + props.events.reactUse("action_toggle_group", event => { + if(event.groupKey !== props.group.key) + return; + + setCollapsed(event.collapsed); + }); + + return <> +
+
+
props.events.fire("action_toggle_group", { collapsed: !collapsed, groupKey: props.group.key})} /> + {props.group.name} +
+ {props.focusEnabled ?
: undefined} +
+
+
+ {!collapsed && props.group.events?.map(e => )} + {!collapsed && props.group.subgroups?.map(e => )} + ; +}; + +const NoFilterResultsEmpty = (props: { shown: boolean }) => ( + +); + +const EventTableBody = (props: { events: Registry, focusEnabled: boolean }) => { + const refContainer = useRef(); + const [ events, setEvents ] = useState<"loading" | EventGroup[]>(() => { + props.events.fire("query_events"); + return "loading"; + }); + + props.events.reactUse("notify_events", event => setEvents(event.groups)); + + return ( +
+ {events === "loading" ? undefined : + events.map(e => ) + } + +
+ ) +}; + +const EventTable = (props: { events: Registry }) => { + const [ focusEnabled, setFocusEnabled] = useState(__build.target === "client"); + + props.events.reactUse("notify_events", event => { + if(event.focusEnabled !== focusEnabled) + setFocusEnabled(event.focusEnabled); + }); + + return ( +
+ + +
+ ); +}; + +const EventFilter = (props: { events: Registry }) => { + return ( +
+ Filter Events} + labelType={"floating"} + onChange={text => props.events.fire_async("action_set_filter", { filter: text })} + /> +
+ ) +}; + +export const NotificationSettings = () => { + const events = useRef>(undefined); + + if(events.current === undefined) { + events.current = new Registry(); + initializeController(events.current); + } + + return (<> +
+ +
+
+ + +
+ ); +}; + +const knownEventGroups: EventGroup[] = [ + { + key: "client", + name: "Client events", + subgroups: [ + { + key: "client-messages", + name: "Messages", + events: [ + EventType.CLIENT_POKE_RECEIVED, + EventType.CLIENT_POKE_SEND, + EventType.PRIVATE_MESSAGE_SEND, + EventType.PRIVATE_MESSAGE_RECEIVED + ] + }, + { + key: "client-view", + name: "View", + events: [ + EventType.CLIENT_VIEW_ENTER, + EventType.CLIENT_VIEW_ENTER_OWN_CHANNEL, + EventType.CLIENT_VIEW_MOVE, + EventType.CLIENT_VIEW_MOVE_OWN, + EventType.CLIENT_VIEW_MOVE_OWN_CHANNEL, + EventType.CLIENT_VIEW_LEAVE, + EventType.CLIENT_VIEW_LEAVE_OWN_CHANNEL + ] + } + ] + }, + { + key: "server", + name: "Server", + events: [ + EventType.GLOBAL_MESSAGE, + EventType.SERVER_CLOSED, + EventType.SERVER_BANNED, + ] + }, + { + key: "connection", + name: "Connection", + events: [ + EventType.CONNECTION_BEGIN, + EventType.CONNECTION_CONNECTED, + EventType.CONNECTION_FAILED + ] + } +]; + +const groupNames: {[key: string]: string} = { }; +groupNames[EventType.CLIENT_POKE_RECEIVED] = tr("You received a poke"); +groupNames[EventType.CLIENT_POKE_SEND] = tr("You send a poke"); +groupNames[EventType.PRIVATE_MESSAGE_SEND] = tr("You received a private message"); +groupNames[EventType.PRIVATE_MESSAGE_RECEIVED] = tr("You send a private message"); + +groupNames[EventType.CLIENT_VIEW_ENTER] = tr("A client enters your view"); +groupNames[EventType.CLIENT_VIEW_ENTER_OWN_CHANNEL] = tr("A client enters your view and your channel"); + +groupNames[EventType.CLIENT_VIEW_MOVE] = tr("A client switches/gets moved/kicked"); +groupNames[EventType.CLIENT_VIEW_MOVE_OWN_CHANNEL] = tr("A client switches/gets moved/kicked in to/out of your channel"); +groupNames[EventType.CLIENT_VIEW_MOVE_OWN] = tr("You've been moved or kicked"); + +groupNames[EventType.CLIENT_VIEW_LEAVE] = tr("A client leaves/disconnects of your view"); +groupNames[EventType.CLIENT_VIEW_LEAVE_OWN_CHANNEL] = tr("A client leaves/disconnects of your channel"); + +groupNames[EventType.GLOBAL_MESSAGE] = tr("A server message has been send"); +groupNames[EventType.SERVER_CLOSED] = tr("The server has been closed"); +groupNames[EventType.SERVER_BANNED] = tr("You've been banned from the server"); + +groupNames[EventType.CONNECTION_BEGIN] = tr("You're connecting to a server"); +groupNames[EventType.CONNECTION_CONNECTED] = tr("You've successfully connected to the server"); +groupNames[EventType.CONNECTION_FAILED] = tr("You're connect attempt failed"); + +function initializeController(events: Registry) { + let filter = undefined; + + events.on(["query_events", "action_set_filter"], event => { + if(event.type === "action_set_filter") + filter = event.as<"action_set_filter">().filter; + + const groupMapper = (group: EventGroup) => { + const result = { + name: group.name, + events: group.events?.filter(e => { + if(!filter) + return true; + + if(e.toLowerCase().indexOf(filter) !== -1) + return true; + + if(!groupNames[e]) + return false; + + return groupNames[e].indexOf(filter) !== -1; + }), + key: group.key, + subgroups: group.subgroups?.map(groupMapper).filter(e => !!e) + } as EventGroup; + + if(!result.subgroups?.length && !result.events?.length) + return undefined; + + return result; + }; + + events.fire_async("notify_events", { + groups: knownEventGroups.map(groupMapper).filter(e => !!e), + focusEnabled: __build.target === "client" + }); + }); + events.on("query_event_info", event => { + events.fire_async("notify_event_info", { + key: event.key, + name: groupNames[event.key] || event.key, + log: settings.global(Settings.FN_EVENTS_LOG_ENABLED(event.key), true) ? "enabled" : "disabled", + notification: getRegisteredNotificationDispatchers().findIndex(e => e as any === event.key) === -1 ? "unavailable" : isNotificationEnabled(event.key as any) ? "enabled" : "disabled", + focus: isFocusRequestEnabled(event.key as any) ? "enabled" : "disabled" + }); + }); + + events.on("action_set_state", event => { + switch (event.state) { + case "log": + settings.changeGlobal(Settings.FN_EVENTS_LOG_ENABLED(event.key), event.value === "enabled"); + break; + + case "notification": + settings.changeGlobal(Settings.FN_EVENTS_NOTIFICATION_ENABLED(event.key), event.value === "enabled"); + break; + + case "focus": + settings.changeGlobal(Settings.FN_EVENTS_FOCUS_ENABLED(event.key), event.value === "enabled"); + break; + } + + events.fire_async("notify_set_state_result", { + key: event.key, + state: event.state, + value: event.value + }); + }); +} \ No newline at end of file