commit 317ee2946183ee0f7723ebc9ec19facf157885db
Author: Tim Düsterhus
Date: Fri Aug 17 00:30:59 2018 +0200
Initial import
diff --git a/.babelrc b/.babelrc
new file mode 100644
index 0000000..768e4c4
--- /dev/null
+++ b/.babelrc
@@ -0,0 +1,17 @@
+{ "presets": [ [ "env"
+ , { "targets": { "browsers": [ "last 2 chrome versions"
+ , "last 2 chromeandroid versions"
+ , "firefox esr"
+ , "not firefox 52"
+ , "last 2 firefox versions"
+ , "edge >= 15"
+ , "safari >= 11"
+ , "ios >= 11"
+ ]
+ }
+ , "debug": true
+ , "include": [ ]
+ }
+ ]
+ ]
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4a2e1de
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+node_modules
+
+be.bastelstu.chat.tar.gz
+be.bastelstu.chat.tar
+files.tar
+files_wcf.tar
+acptemplates.tar
+templates.tar
+
+Bastelstu.be.Chat.js
+Bastelstu.be.Chat.babel.js
+
+files_wcf/js/Bastelstu.be.Chat.min.js
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..83dcefb
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,104 @@
+License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
+“Business Source License” is a trademark of MariaDB Corporation Ab.
+
+Parameters
+
+Licensor: Tim Düsterhus
+Licensed Work: Tim’s Chat 4.0
+ The Licensed Work is (c) 2010-2018 Tim Düsterhus
+Additional Use Grant: You may use the Licensed Work when your application
+ uses the Licensed Work for a purpose that does neither
+ directly or indirectly generate revenue.
+
+Change Date: 2022-08-16
+
+Change License: Version 2 or later of the GNU General Public License as
+ published by the Free Software Foundation.
+
+For information about alternative licensing arrangements for the Software,
+please email: tim bastelstu.be
+
+Notice
+
+The Business Source License (this document, or the “License”) is not an Open
+Source license. However, the Licensed Work will eventually be made available
+under an Open Source License, as stated in this License.
+
+For more information on the use of the Business Source License for MariaDB
+products, please visit the MariaDB Business Source License FAQ at
+https://mariadb.com/bsl-faq-mariadb.
+
+For more information on the use of the Business Source License generally,
+please visit the Adopting and Developing Business Source License FAQ at
+https://mariadb.com/bsl-faq-adopting.
+
+-----------------------------------------------------------------------------
+
+Business Source License 1.1
+
+Terms
+
+The Licensor hereby grants you the right to copy, modify, create derivative
+works, redistribute, and make non-production use of the Licensed Work. The
+Licensor may make an Additional Use Grant, above, permitting limited
+production use.
+
+Effective on the Change Date, or the fourth anniversary of the first publicly
+available distribution of a specific version of the Licensed Work under this
+License, whichever comes first, the Licensor hereby grants you rights under
+the terms of the Change License, and the rights granted in the paragraph
+above terminate.
+
+If your use of the Licensed Work does not comply with the requirements
+currently in effect as described in this License, you must purchase a
+commercial license from the Licensor, its affiliated entities, or authorized
+resellers, or you must refrain from using the Licensed Work.
+
+All copies of the original and modified Licensed Work, and derivative works
+of the Licensed Work, are subject to this License. This License applies
+separately for each version of the Licensed Work and the Change Date may vary
+for each version of the Licensed Work released by Licensor.
+
+You must conspicuously display this License on each original or modified copy
+of the Licensed Work. If you receive the Licensed Work in original or
+modified form from a third party, the terms and conditions set forth in this
+License apply to your use of that work.
+
+Any use of the Licensed Work in violation of this License will automatically
+terminate your rights under this License for the current and all other
+versions of the Licensed Work.
+
+This License does not grant you any right in any trademark or logo of
+Licensor or its affiliates (provided that you may use a trademark or logo of
+Licensor as expressly required by this License).
+
+TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
+AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
+EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
+TITLE.
+
+MariaDB hereby grants you permission to use this License’s text to license
+your works, and to refer to it using the trademark “Business Source License”,
+as long as you comply with the Covenants of Licensor below.
+
+Covenants of Licensor
+
+In consideration of the right to use this License’s text and the “Business
+Source License” name and trademark, Licensor covenants to MariaDB, and to all
+other recipients of the licensed work to be provided by Licensor:
+
+1. To specify as the Change License the GPL Version 2.0 or any later version,
+ or a license that is compatible with GPL Version 2.0 or a later version,
+ where “compatible” means that software provided under the Change License can
+ be included in a program with software provided under GPL Version 2.0 or a
+ later version. Licensor may specify additional Change Licenses without
+ limitation.
+
+2. To either: (a) specify an additional grant of rights to use that does not
+ impose any additional restriction on the right granted in this License, as
+ the Additional Use Grant; or (b) insert the text “None”.
+
+3. To specify a Change Date.
+
+4. Not to modify this License in any other way.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..14318cd
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,43 @@
+FILES = $(shell find files -type f)
+WCF_FILES = $(shell find files_wcf -type f)
+JS_MODULE_FILES = $(shell find files_wcf/js/Bastelstu.be -type f)
+
+all: be.bastelstu.chat.tar be.bastelstu.chat.tar.gz
+
+be.bastelstu.chat.tar.gz: be.bastelstu.chat.tar
+ gzip -9 < $< > $@
+
+be.bastelstu.chat.tar: files.tar files_wcf.tar acptemplates.tar templates.tar *.xml LICENSE sql/*.sql language/*.xml
+ tar cvf be.bastelstu.chat.tar --numeric-owner --exclude-vcs -- $^
+
+files.tar: $(FILES)
+files_wcf.tar: $(WCF_FILES) files_wcf/js/Bastelstu.be.Chat.min.js
+acptemplates.tar: acptemplates/*.tpl
+templates.tar: templates/*.tpl
+
+%.tar:
+ tar cvf $@ --numeric-owner --exclude-vcs -C $* -- $(^:$*/%=%)
+
+files_wcf/js/Bastelstu.be.Chat.min.js: Bastelstu.be.Chat.babel.js
+ yarn run terser --comments '/Copyright|stackoverflow/' -m -c pure_funcs=[console.debug] --verbose --timings -o $@ $^
+
+Bastelstu.be.Chat.babel.js: Bastelstu.be.Chat.js .babelrc
+ yarn run babel $< --out-file $@
+
+Bastelstu.be.Chat.js: $(JS_MODULE_FILES)
+ yarn run r.js -o require.build.js
+
+clean:
+ -rm -f files.tar
+ -rm -f files_wcf.tar
+ -rm -f templates.tar
+ -rm -f acptemplates.tar
+ -rm -f Bastelstu.be.Chat.js
+ -rm -f Bastelstu.be.Chat.babel.js
+ -rm -f files_wcf/js/Bastelstu.be.Chat.min.js
+
+distclean: clean
+ -rm -f be.bastelstu.chat.tar
+ -rm -f be.bastelstu.chat.tar.gz
+
+.PHONY: distclean clean
diff --git a/aclOption.xml b/aclOption.xml
new file mode 100644
index 0000000..2f8117d
--- /dev/null
+++ b/aclOption.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+ be.bastelstu.chat.room
+
+
+ be.bastelstu.chat.room
+
+
+
+
+
+ be.bastelstu.chat.room
+ user
+
+
+ be.bastelstu.chat.room
+ user
+
+
+ be.bastelstu.chat.room
+ user
+
+
+ be.bastelstu.chat.room
+ mod
+
+
+ be.bastelstu.chat.room
+ mod
+
+
+ be.bastelstu.chat.room
+ mod
+
+
+ be.bastelstu.chat.room
+ mod
+
+
+ be.bastelstu.chat.room
+ mod
+
+
+
+
diff --git a/acpMenu.xml b/acpMenu.xml
new file mode 100644
index 0000000..b3b6e54
--- /dev/null
+++ b/acpMenu.xml
@@ -0,0 +1,42 @@
+
+
+
+
+ wcf.acp.menu.link.application
+
+
+
+ chat\acp\page\RoomListPage
+ chat.acp.menu.link.chat
+ admin.chat.canManageRoom
+ 1
+
+
+
+ chat\acp\form\RoomAddForm
+ chat.acp.menu.link.room.list
+ admin.chat.canManageRoom
+ fa-plus
+
+
+
+ chat\acp\page\CommandTriggerListPage
+ chat.acp.menu.link.chat
+ admin.chat.canManageTriggers
+
+
+
+ chat\acp\form\CommandTriggerAddForm
+ chat.acp.menu.link.command.trigger.list
+ admin.chat.canManageTriggers
+ fa-plus
+
+
+
+ chat\acp\page\SuspensionListPage
+ chat.acp.menu.link.chat
+ admin.chat.canManageSuspensions
+
+
+
+
diff --git a/acptemplates/__chatVersion.tpl b/acptemplates/__chatVersion.tpl
new file mode 100644
index 0000000..2a0d898
--- /dev/null
+++ b/acptemplates/__chatVersion.tpl
@@ -0,0 +1,5 @@
+
+ {lang}chat.acp.index.system.software.chatVersion{/lang}
+ {$__chat->getPackage()->packageVersion}
+
+
diff --git a/acptemplates/commandTriggerAdd.tpl b/acptemplates/commandTriggerAdd.tpl
new file mode 100644
index 0000000..9b4804d
--- /dev/null
+++ b/acptemplates/commandTriggerAdd.tpl
@@ -0,0 +1,72 @@
+{include file='header' pageTitle='chat.acp.command.trigger.'|concat:$action}
+
+
+
+{include file='formError'}
+
+{if $success|isset}
+ {lang}wcf.global.success.{$action}{/lang}
+{/if}
+
+
+
+{include file='footer'}
+
diff --git a/acptemplates/commandTriggerList.tpl b/acptemplates/commandTriggerList.tpl
new file mode 100644
index 0000000..07fac70
--- /dev/null
+++ b/acptemplates/commandTriggerList.tpl
@@ -0,0 +1,86 @@
+{include file='header' pageTitle='chat.acp.command.trigger.list'}
+
+
+
+
+
+
+{hascontent}
+
+{/hascontent}
+
+{hascontent}
+
+{hascontentelse}
+ {lang}wcf.global.noItems{/lang}
+{/hascontent}
+
+
+
+{include file='footer'}
+
diff --git a/acptemplates/roomAdd.tpl b/acptemplates/roomAdd.tpl
new file mode 100644
index 0000000..944cf68
--- /dev/null
+++ b/acptemplates/roomAdd.tpl
@@ -0,0 +1,113 @@
+{include file='header' pageTitle='chat.acp.room.'|concat:$action}
+
+{include file='aclPermissions'}
+
+{include file='multipleLanguageInputJavascript' elementIdentifier='title' forceSelection=false}
+{include file='multipleLanguageInputJavascript' elementIdentifier='topic' forceSelection=false}
+
+{if $roomID|isset}
+ {include file='aclPermissionJavaScript' containerID='aclContainer' objectTypeID=$aclObjectTypeID objectID=$roomID}
+{else}
+ {include file='aclPermissionJavaScript' containerID='aclContainer' objectTypeID=$aclObjectTypeID}
+{/if}
+
+
+
+{include file='formError'}
+
+{if $success|isset}
+ {lang}wcf.global.success.{$action}{/lang}
+{/if}
+
+
+
+{include file='footer'}
+
diff --git a/acptemplates/roomList.tpl b/acptemplates/roomList.tpl
new file mode 100644
index 0000000..fa9613c
--- /dev/null
+++ b/acptemplates/roomList.tpl
@@ -0,0 +1,81 @@
+{include file='header' pageTitle='chat.acp.room.list'}
+
+
+
+
+
+{hascontent}
+
+{/hascontent}
+
+{hascontent}
+
+
+ {content}
+ {foreach from=$objects item=room}
+
+
+ {$room}
+
+
+
+
+
+ {event name='itemButtons'}
+
+
+
+ {/foreach}
+ {/content}
+
+
+
+
+ {lang}wcf.global.button.saveSorting{/lang}
+
+{hascontentelse}
+ {lang}wcf.global.noItems{/lang}
+{/hascontent}
+
+
+
+{include file='footer'}
+
diff --git a/acptemplates/suspensionList.tpl b/acptemplates/suspensionList.tpl
new file mode 100644
index 0000000..37f189d
--- /dev/null
+++ b/acptemplates/suspensionList.tpl
@@ -0,0 +1,192 @@
+{include file='header' pageTitle='chat.acp.suspension.list'}
+
+
+
+
+
+
+
+
+{capture assign=additionalParameters}{*
+ *}{if $userID !== null}&userID={$userID}{/if}{*
+ *}{if $judgeID !== null}&judgeID={$judgeID}{/if}{*
+ *}{if $roomID !== null}&roomID={$roomID}{/if}{*
+ *}{if $objectTypeID !== null}&objectTypeID={$objectTypeID}{/if}{*
+ *}{if $showExpired !== null}&showExpired={$showExpired}{/if}{*
+*}{/capture}
+
+{hascontent}
+
+{/hascontent}
+
+{hascontent}
+
+{hascontentelse}
+ {lang}wcf.global.noItems{/lang}
+{/hascontent}
+
+
+
+{include file='footer'}
+
diff --git a/box.xml b/box.xml
new file mode 100644
index 0000000..35d6ebc
--- /dev/null
+++ b/box.xml
@@ -0,0 +1,42 @@
+
+
+
+
+ Chaträume (Inhaltsbereich)
+ Chat Rooms (Content)
+ system
+ be.bastelstu.chat.roomList
+ contentTop
+ 1
+ 0
+
+ com.woltlab.wcf.Dashboard
+
+
+ Chaträume
+
+
+ Chat Rooms
+
+
+
+
+ Chaträume (Seitenleiste)
+ Chat Rooms (Sidebar)
+ system
+ be.bastelstu.chat.roomList
+ sidebarRight
+ 1
+ 0
+
+ be.bastelstu.chat.Room
+
+
+ Chaträume
+
+
+ Chat Rooms
+
+
+
+
diff --git a/chatCommand.xml b/chatCommand.xml
new file mode 100644
index 0000000..d07ecc7
--- /dev/null
+++ b/chatCommand.xml
@@ -0,0 +1,103 @@
+
+
+
+
+ chat\system\command\AwayCommand
+
+ away
+
+
+
+
+ chat\system\command\BackCommand
+
+
+
+ chat\system\command\BanCommand
+
+ ban
+
+
+
+
+ chat\system\command\BroadcastCommand
+
+ broadcast
+
+
+
+
+ chat\system\command\ColorCommand
+
+ color
+
+
+
+
+ chat\system\command\InfoCommand
+
+ info
+
+
+
+
+ chat\system\command\MeCommand
+
+ me
+
+
+
+
+ chat\system\command\MuteCommand
+
+ mute
+
+
+
+
+ chat\system\command\PlainCommand
+
+
+
+ chat\system\command\TeamCommand
+
+ team
+
+
+
+
+ chat\system\command\TemproomCommand
+
+ temproom
+
+
+
+
+ chat\system\command\UnbanCommand
+
+ unban
+
+
+
+
+ chat\system\command\UnmuteCommand
+
+ unmute
+
+
+
+
+ chat\system\command\WhereCommand
+
+ where
+
+
+
+
+ chat\system\command\WhisperCommand
+
+ whisper
+
+
+
+
diff --git a/eventListener.xml b/eventListener.xml
new file mode 100644
index 0000000..5ea4b5c
--- /dev/null
+++ b/eventListener.xml
@@ -0,0 +1,166 @@
+
+
+
+
+
+ wcf\system\cronjob\HourlyCleanUpCronjob
+ execute
+ chat\system\event\listener\HourlyCleanUpCronjobExecuteChatCleanUpListener
+ user
+
+
+ wcf\system\cronjob\HourlyCleanUpCronjob
+ execute
+ chat\system\event\listener\HourlyCleanUpCronjobExecuteChatCleanUpListener
+ admin
+
+
+
+
+ wcf\system\cronjob\HourlyCleanUpCronjob
+ execute
+ chat\system\event\listener\HourlyCleanUpCronjobExecuteTemproomListener
+ user
+
+
+ wcf\system\cronjob\HourlyCleanUpCronjob
+ execute
+ chat\system\event\listener\HourlyCleanUpCronjobExecuteTemproomListener
+ admin
+
+
+ chat\data\room\Room
+ canSee
+ chat\system\event\listener\RoomCanSeeTemproomListener
+ user
+
+
+ chat\acp\page\RoomListPage
+ calculateNumberOfPages
+ chat\system\event\listener\RoomListPageTemproomListener
+ admin
+
+
+ chat\acp\form\RoomEditForm
+ readParameters
+ chat\system\event\listener\RoomEditFormTemproomListener
+ admin
+
+
+ chat\acp\page\SuspensionListPage
+ readData
+ chat\system\event\listener\SuspensionListPageTemproomListener
+ admin
+
+
+
+
+ chat\data\room\Room
+ canJoin
+ chat\system\event\listener\RoomCanJoinUserLimitListener
+ user
+
+
+
+
+ chat\data\room\Room
+ canJoin
+ chat\system\event\listener\RoomCanJoinBanListener
+ user
+
+
+ chat\data\room\Room
+ canWritePublicly
+ chat\system\event\listener\RoomCanWritePubliclyMuteListener
+ user
+
+
+ chat\system\command\InfoCommand
+ execute
+ chat\system\event\listener\InfoCommandSuspensionsListener
+ user
+
+
+ chat\data\room\RoomAction
+ getUsers
+ chat\system\event\listener\RoomActionGetUsersModeratorListener
+ user
+
+
+
+
+
+
+ wcf\system\cronjob\HourlyCleanUpCronjob
+ execute
+ chat\system\event\listener\HourlyCleanUpCronjobExecuteChatCleanUpListener
+ user
+
+
+ wcf\system\cronjob\HourlyCleanUpCronjob
+ execute
+ chat\system\event\listener\HourlyCleanUpCronjobExecuteChatCleanUpListener
+ admin
+
+
+
+
+ wcf\system\cronjob\HourlyCleanUpCronjob
+ execute
+ chat\system\event\listener\HourlyCleanUpCronjobExecuteTemproomListener
+ user
+
+
+ wcf\system\cronjob\HourlyCleanUpCronjob
+ execute
+ chat\system\event\listener\HourlyCleanUpCronjobExecuteTemproomListener
+ admin
+
+
+ chat\data\room\Room
+ canSee
+ chat\system\event\listener\RoomCanSeeTemproomListener
+ user
+
+
+ chat\acp\page\RoomListPage
+ calculateNumberOfPages
+ chat\system\event\listener\RoomListPageTemproomListener
+ admin
+
+
+ chat\acp\form\RoomEditForm
+ readParameters
+ chat\system\event\listener\RoomEditFormTemproomListener
+ admin
+
+
+
+
+ chat\data\room\Room
+ canJoin
+ chat\system\event\listener\RoomCanJoinUserLimitListener
+ user
+
+
+
+
+ chat\data\room\Room
+ canJoin
+ chat\system\event\listener\RoomCanJoinBanListener
+ user
+
+
+ chat\data\room\Room
+ canWritePublicly
+ chat\system\event\listener\RoomCanWritePubliclyMuteListener
+ user
+
+
+ chat\system\command\InfoCommand
+ execute
+ chat\system\event\listener\InfoCommandSuspensionsListener
+ user
+
+
+
diff --git a/files/acp/be.bastelstu.chat_install.php b/files/acp/be.bastelstu.chat_install.php
new file mode 100644
index 0000000..3105d64
--- /dev/null
+++ b/files/acp/be.bastelstu.chat_install.php
@@ -0,0 +1,21 @@
+createBoxCondition( 'be.bastelstu.chat.roomListDashboard'
+ , 'be.bastelstu.chat.box.roomList.condition'
+ , 'be.bastelstu.chat.roomFilled'
+ , [ 'chatRoomIsFilled' => 1 ]
+ );
diff --git a/files/acp/be.bastelstu.chat_update.php b/files/acp/be.bastelstu.chat_update.php
new file mode 100644
index 0000000..3ca26ae
--- /dev/null
+++ b/files/acp/be.bastelstu.chat_update.php
@@ -0,0 +1,35 @@
+getObjectTypeIDByName('be.bastelstu.chat.messageType', 'be.bastelstu.chat.messageType.chatUpdate');
+
+if ($objectTypeID) {
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => null
+ , 'userID' => null
+ , 'username' => ''
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ ])
+ ]
+ ]
+ )
+ )->executeAction();
+}
+
+$CHATCore = file_get_contents(__DIR__.'/../lib/system/CHATCore.class.php');
+if (strpos($CHATCore, 'chat.phar.php') === false) {
+ @unlink(__DIR__.'/../chat.phar.php');
+}
diff --git a/files/acp/global.php b/files/acp/global.php
new file mode 100644
index 0000000..c3243f6
--- /dev/null
+++ b/files/acp/global.php
@@ -0,0 +1,18 @@
+handle('chat', true);
diff --git a/files/global.php b/files/global.php
new file mode 100644
index 0000000..8ee87b1
--- /dev/null
+++ b/files/global.php
@@ -0,0 +1,16 @@
+handle('chat');
diff --git a/files/lib/acp/form/CommandTriggerAddForm.class.php b/files/lib/acp/form/CommandTriggerAddForm.class.php
new file mode 100644
index 0000000..9ff850f
--- /dev/null
+++ b/files/lib/acp/form/CommandTriggerAddForm.class.php
@@ -0,0 +1,159 @@
+sqlOrderBy = 'command.className';
+ $commandList->readObjects();
+
+ $this->commands = $commandList->getObjects();
+
+ parent::readData();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function readFormParameters() {
+ parent::readFormParameters();
+
+ if (isset($_POST['commandTrigger'])) $this->commandTrigger = \wcf\util\StringUtil::trim($_POST['commandTrigger']);
+ if (isset($_POST['className'])) $this->className = \wcf\util\StringUtil::trim($_POST['className']);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function validate() {
+ parent::validate();
+
+ if (empty($this->commandTrigger)) {
+ throw new UserInputException('commandTrigger', 'empty');
+ }
+
+ // Triggers must not contain whitespace
+ if (preg_match('~\s~', $this->commandTrigger)) {
+ throw new UserInputException('commandTrigger', 'invalid');
+ }
+
+ // Check for duplicates
+ $trigger = CommandTrigger::getTriggerByName($this->commandTrigger);
+ if ((!isset($this->trigger) && $trigger->triggerID) || (isset($this->trigger) && $trigger->triggerID != $this->trigger->triggerID)) {
+ throw new UserInputException('commandTrigger', 'duplicate');
+ }
+
+ if (empty($this->className)) {
+ throw new UserInputException('className', 'empty');
+ }
+
+ // Check if the command is registered
+ foreach ($this->commands as $command) {
+ if ($command->className === $this->className) {
+ $this->command = $command;
+ break;
+ }
+ }
+
+ if (!$this->command) {
+ throw new UserInputException('className', 'notFound');
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function save() {
+ parent::save();
+
+ $fields = [ 'commandTrigger' => $this->commandTrigger
+ , 'commandID' => $this->command->commandID
+ ];
+
+ // create room
+ $this->objectAction = new \chat\data\command\CommandTriggerAction([ ], 'create', [ 'data' => array_merge($this->additionalFields, $fields) ]);
+ $this->objectAction->executeAction();
+
+ $this->saved();
+
+ // reset values
+ $this->commandTrigger = $this->className = '';
+
+ // show success message
+ WCF::getTPL()->assign('success', true);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function assignVariables() {
+ parent::assignVariables();
+
+ WCF::getTPL()->assign([ 'action' => 'add'
+ , 'commandTrigger' => $this->commandTrigger
+ , 'className' => $this->className
+ , 'availableCommands' => $this->commands
+ ]);
+ }
+}
diff --git a/files/lib/acp/form/CommandTriggerEditForm.class.php b/files/lib/acp/form/CommandTriggerEditForm.class.php
new file mode 100644
index 0000000..6acea7f
--- /dev/null
+++ b/files/lib/acp/form/CommandTriggerEditForm.class.php
@@ -0,0 +1,112 @@
+triggerID = intval($_REQUEST['id']);
+ $this->trigger = new CommandTrigger($this->triggerID);
+
+ if (!$this->trigger) {
+ throw new IllegalLinkException();
+ }
+
+ parent::readParameters();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function readData() {
+ parent::readData();
+
+ if (empty($_POST)) {
+ $commandList = new \chat\data\command\CommandList();
+ $commandList->getConditionBuilder()->add('command.commandID = ?', [ $this->trigger->commandID ]);
+ $commandList->readObjects();
+ $commands = $commandList->getObjects();
+
+ if (!count($commands)) {
+ throw new IllegalLinkException();
+ }
+
+ $this->commandTrigger = $this->trigger->commandTrigger;
+ $this->className = $commands[$this->trigger->commandID]->className;
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function save() {
+ \wcf\form\AbstractForm::save();
+
+ $fields = [ 'commandTrigger' => $this->commandTrigger
+ , 'commandID' => $this->command->commandID
+ ];
+
+ // update trigger
+ $this->objectAction = new CommandTriggerAction([ $this->trigger ], 'update', [ 'data' => array_merge($this->additionalFields, $fields) ]);
+ $this->objectAction->executeAction();
+
+ $this->saved();
+
+ // show success message
+ WCF::getTPL()->assign('success', true);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function assignVariables() {
+ parent::assignVariables();
+
+ WCF::getTPL()->assign([ 'action' => 'edit'
+ , 'triggerID' => $this->trigger->triggerID
+ ]);
+ }
+}
diff --git a/files/lib/acp/form/RoomAddForm.class.php b/files/lib/acp/form/RoomAddForm.class.php
new file mode 100644
index 0000000..1d85b19
--- /dev/null
+++ b/files/lib/acp/form/RoomAddForm.class.php
@@ -0,0 +1,215 @@
+register('title');
+ I18nHandler::getInstance()->register('topic');
+
+ $this->aclObjectTypeID = ACLHandler::getInstance()->getObjectTypeID('be.bastelstu.chat.room');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function readFormParameters() {
+ parent::readFormParameters();
+
+ // read i18n values
+ I18nHandler::getInstance()->readValues();
+
+ // handle i18n plain input
+ if (I18nHandler::getInstance()->isPlainValue('title')) $this->title = I18nHandler::getInstance()->getValue('title');
+ if (I18nHandler::getInstance()->isPlainValue('topic')) $this->topic = I18nHandler::getInstance()->getValue('topic');
+ if (isset($_POST['userLimit'])) $this->userLimit = intval($_POST['userLimit']);
+ if (isset($_POST['topicUseHtml'])) $this->topicUseHtml = true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function validate() {
+ parent::validate();
+
+ // validate title
+ if (!I18nHandler::getInstance()->validateValue('title')) {
+ if (I18nHandler::getInstance()->isPlainValue('title')) {
+ throw new UserInputException('title');
+ }
+ else {
+ throw new UserInputException('title', 'multilingual');
+ }
+ }
+
+ // validate topic
+ if (!I18nHandler::getInstance()->validateValue('topic', false, true)) {
+ throw new UserInputException('topic');
+ }
+
+ if (mb_strlen($this->topic) > 10000) {
+ throw new UserInputException('topic', 'tooLong');
+ }
+
+ if ($this->userLimit < 0) {
+ throw new UserInputException('userLimit', 'negative');
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function save() {
+ parent::save();
+
+ $fields = [ 'title' => $this->title
+ , 'topic' => $this->topic
+ , 'topicUseHtml' => (int) $this->topicUseHtml
+ , 'userLimit' => $this->userLimit
+ , 'position' => 0 // TODO
+ ];
+
+ // create room
+ $this->objectAction = new \chat\data\room\RoomAction([], 'create', [ 'data' => array_merge($this->additionalFields, $fields) ]);
+ $returnValues = $this->objectAction->executeAction();
+
+ // save i18n values
+ $this->saveI18nValue($returnValues['returnValues'], [ 'title', 'topic' ]);
+
+ // save ACL
+ ACLHandler::getInstance()->save($returnValues['returnValues']->roomID, $this->aclObjectTypeID);
+
+ $this->saved();
+
+ // reset values
+ $this->title = $this->topic = '';
+ $this->userLimit = 0;
+ $this->topicUseHtml = false;
+
+ I18nHandler::getInstance()->reset();
+ ACLHandler::getInstance()->disableAssignVariables();
+
+ // show success message
+ WCF::getTPL()->assign('success', true);
+ }
+
+ /**
+ * Saves i18n values.
+ *
+ * @param Room $room
+ * @param string[] $columns
+ */
+ public function saveI18nValue(Room $room, $columns) {
+ $data = [ ];
+
+ foreach ($columns as $columnName) {
+ $languageItem = 'chat.room.room'.$room->roomID.'.'.$columnName;
+
+ if (I18nHandler::getInstance()->isPlainValue($columnName)) {
+ if ($room->$columnName === $languageItem) {
+ I18nHandler::getInstance()->remove($languageItem);
+ }
+ }
+ else {
+ $packageID = \wcf\data\package\PackageCache::getInstance()->getPackageID('be.bastelstu.chat');
+
+ I18nHandler::getInstance()->save( $columnName
+ , $languageItem
+ , 'chat.room'
+ , $packageID
+ );
+
+ $data[$columnName] = $languageItem;
+ }
+ }
+
+ if (!empty($data)) {
+ (new RoomEditor($room))->update($data);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function assignVariables() {
+ parent::assignVariables();
+
+ ACLHandler::getInstance()->assignVariables($this->aclObjectTypeID);
+ I18nHandler::getInstance()->assignVariables();
+
+ WCF::getTPL()->assign([ 'action' => 'add'
+ , 'aclObjectTypeID' => $this->aclObjectTypeID
+ , 'userLimit' => $this->userLimit
+ , 'topicUseHtml' => $this->topicUseHtml
+ ]);
+ }
+}
+
diff --git a/files/lib/acp/form/RoomEditForm.class.php b/files/lib/acp/form/RoomEditForm.class.php
new file mode 100644
index 0000000..9c9e75f
--- /dev/null
+++ b/files/lib/acp/form/RoomEditForm.class.php
@@ -0,0 +1,117 @@
+roomID = intval($_REQUEST['id']);
+ $this->room = new Room($this->roomID);
+
+ if (!$this->room) {
+ throw new IllegalLinkException();
+ }
+
+ parent::readParameters();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function readData() {
+ parent::readData();
+
+ if (empty($_POST)) {
+ $packageID = \wcf\data\package\PackageCache::getInstance()->getPackageID('be.bastelstu.chat');
+ I18nHandler::getInstance()->setOptions('title', $packageID, $this->room->title, 'chat.room.room\d+.title');
+ I18nHandler::getInstance()->setOptions('topic', $packageID, $this->room->topic, 'chat.room.room\d+.topic');
+ $this->userLimit = $this->room->userLimit;
+ $this->topicUseHtml = $this->room->topicUseHtml;
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function save() {
+ \wcf\form\AbstractForm::save();
+
+ $fields = [ 'title' => $this->title
+ , 'topic' => $this->topic
+ , 'topicUseHtml' => (int) $this->topicUseHtml
+ , 'userLimit' => $this->userLimit
+ , 'position' => 0 // TODO
+ ];
+
+ // update room
+ $this->objectAction = new RoomAction([ $this->room ], 'update', [ 'data' => array_merge($this->additionalFields, $fields) ]);
+ $returnValues = $this->objectAction->executeAction();
+
+ // save i18n values
+ $this->saveI18nValue($this->room, [ 'title', 'topic' ]);
+
+ // save ACL
+ ACLHandler::getInstance()->save($this->room->roomID, $this->aclObjectTypeID);
+
+ $this->saved();
+
+ // show success message
+ WCF::getTPL()->assign('success', true);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function assignVariables() {
+ parent::assignVariables();
+
+ I18nHandler::getInstance()->assignVariables(!empty($_POST));
+
+ WCF::getTPL()->assign([ 'action' => 'edit'
+ , 'roomID' => $this->room->roomID
+ , 'room' => $this->room
+ ]);
+ }
+}
diff --git a/files/lib/acp/page/CommandTriggerListPage.class.php b/files/lib/acp/page/CommandTriggerListPage.class.php
new file mode 100644
index 0000000..6d65515
--- /dev/null
+++ b/files/lib/acp/page/CommandTriggerListPage.class.php
@@ -0,0 +1,55 @@
+objectList->sqlSelects = 'command.className';
+ $this->objectList->sqlJoins = 'LEFT JOIN chat'.WCF_N.'_command command ON (command.commandID = command_trigger.commandID)';
+ }
+}
diff --git a/files/lib/acp/page/RoomListPage.class.php b/files/lib/acp/page/RoomListPage.class.php
new file mode 100644
index 0000000..0b373ab
--- /dev/null
+++ b/files/lib/acp/page/RoomListPage.class.php
@@ -0,0 +1,45 @@
+roomID = intval($_REQUEST['roomID']);
+ if (isset($_REQUEST['userID']) && $_REQUEST['userID'] !== '') $this->userID = intval($_REQUEST['userID']);
+ if (isset($_REQUEST['judgeID']) && $_REQUEST['judgeID'] !== '') $this->judgeID = intval($_REQUEST['judgeID']);
+ if (isset($_REQUEST['objectTypeID']) && $_REQUEST['objectTypeID'] !== '') $this->objectTypeID = intval($_REQUEST['objectTypeID']);
+ // Checkboxes need special handling
+ if (!empty($_POST) && !isset($_POST['showExpired'])) $this->showExpired = false;
+
+ if (isset($_POST['searchUsername'])) {
+ $this->searchUsername = StringUtil::trim($_POST['searchUsername']);
+
+ if (!empty($this->searchUsername)) {
+ $this->userID = User::getUserByUsername($this->searchUsername)->userID;
+ }
+ }
+ else if ($this->userID !== null) {
+ $this->searchUsername = (new User($this->userID))->username;
+ }
+
+ if (isset($_POST['searchJudge'])) {
+ $this->searchJudge = StringUtil::trim($_POST['searchJudge']);
+
+ if (!empty($this->searchJudge)) {
+ $this->judgeID = User::getUserByUsername($this->searchJudge)->userID;
+ }
+ }
+ else if ($this->judgeID !== null) {
+ $this->searchJudge = (new User($this->judgeID))->username;
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function readData() {
+ $this->availableObjectTypes = \wcf\data\object\type\ObjectTypeCache::getInstance()->getObjectTypes('be.bastelstu.chat.suspension');
+
+ $roomList = new \chat\data\room\RoomList();
+ $roomList->sqlOrderBy = "room.position";
+ $roomList->readObjects();
+ $this->availableRooms = $roomList->getObjects();
+
+ parent::readData();
+
+ \wcf\system\cache\runtime\UserRuntimeCache::getInstance()->cacheObjectIDs(array_map(function (Suspension $s) {
+ return $s->userID;
+ }, $this->objectList->getObjects()));
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function initObjectList() {
+ parent::initObjectList();
+
+ $this->objectList->sqlSelects .= 'COALESCE(suspension.revoked, suspension.expires, 2147483647) AS expiresSort';
+
+ if (!empty($this->availableRooms)) {
+ $this->objectList->getConditionBuilder()->add('(roomID IN (?) OR roomID IS NULL)', [ array_map(function (Room $room) {
+ return $room->roomID;
+ }, $this->availableRooms) ]);
+ }
+ else {
+ $this->objectList->getConditionBuilder()->add('1 = 0');
+ }
+
+ if ($this->userID !== null) {
+ $this->objectList->getConditionBuilder()->add('userID = ?', [ $this->userID ]);
+ }
+
+ if ($this->roomID !== null) {
+ if ($this->roomID === 0) {
+ $this->objectList->getConditionBuilder()->add('roomID IS NULL');
+ }
+ else {
+ $this->objectList->getConditionBuilder()->add('roomID = ?', [ $this->roomID ]);
+ }
+ }
+
+ if ($this->objectTypeID !== null) {
+ $this->objectList->getConditionBuilder()->add('objectTypeID = ?', [ $this->objectTypeID ]);
+ }
+
+ if ($this->judgeID !== null) {
+ $this->objectList->getConditionBuilder()->add('judgeID = ?', [ $this->judgeID ]);
+ }
+
+ if ($this->showExpired === false) {
+ $this->objectList->getConditionBuilder()->add('expires >= ?', [ TIME_NOW ]);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function assignVariables() {
+ parent::assignVariables();
+
+ WCF::getTPL()->assign([ 'userID' => $this->userID
+ , 'roomID' => $this->roomID
+ , 'objectTypeID' => $this->objectTypeID
+ , 'judgeID' => $this->judgeID
+ , 'availableRooms' => $this->availableRooms
+ , 'availableObjectTypes' => $this->availableObjectTypes
+ , 'searchUsername' => $this->searchUsername
+ , 'searchJudge' => $this->searchJudge
+ , 'showExpired' => $this->showExpired
+ ]);
+ }
+}
diff --git a/files/lib/data/command/Command.class.php b/files/lib/data/command/Command.class.php
new file mode 100644
index 0000000..ecb70e4
--- /dev/null
+++ b/files/lib/data/command/Command.class.php
@@ -0,0 +1,51 @@
+getPackageID('be.bastelstu.chat');
+ }
+
+ if ($this->packageID === $chatPackageID && $this->identifier === 'plain') {
+ return true;
+ }
+
+ $sql = "SELECT COUNT(*)
+ FROM chat".WCF_N."_command_trigger
+ WHERE commandID = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ $this->commandID ]);
+ return $statement->fetchSingleColumn() > 0;
+ }
+}
diff --git a/files/lib/data/command/CommandCache.class.php b/files/lib/data/command/CommandCache.class.php
new file mode 100644
index 0000000..59102c7
--- /dev/null
+++ b/files/lib/data/command/CommandCache.class.php
@@ -0,0 +1,110 @@
+getData();
+
+ $this->commands = $data['commands'];
+ $this->packages = $data['packages'];
+ $this->triggers = $data['triggers'];
+ }
+
+ /**
+ * Returns a specific command.
+ *
+ * @param integer $commandID
+ * @return Command
+ */
+ public function getCommand($commandID) {
+ if (isset($this->commands[$commandID])) {
+ return $this->commands[$commandID];
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns a specific command defined by a trigger.
+ *
+ * @param string $trigger
+ * @return Command
+ */
+ public function getCommandByTrigger($trigger) {
+ if (isset($this->triggers[$trigger])) {
+ return $this->commands[$this->triggers[$trigger]];
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the command defined by the given package and identifier.
+ *
+ * @param \wcf\data\package\Package $package
+ * @param string $identifier
+ * @return Command
+ */
+ public function getCommandByPackageAndIdentifier(\wcf\data\package\Package $package, $identifier) {
+ if (isset($this->packages[$package->packageID][$identifier])) {
+ return $this->packages[$package->packageID][$identifier];
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns all commands.
+ *
+ * @return Command[]
+ */
+ public function getCommands() {
+ return $this->commands;
+ }
+
+ /**
+ * Returns all triggers.
+ *
+ * @return int[]
+ */
+ public function getTriggers() {
+ return $this->triggers;
+ }
+}
diff --git a/files/lib/data/command/CommandEditor.class.php b/files/lib/data/command/CommandEditor.class.php
new file mode 100644
index 0000000..be4cb42
--- /dev/null
+++ b/files/lib/data/command/CommandEditor.class.php
@@ -0,0 +1,25 @@
+commandTrigger;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getObjectID() {
+ return $this->triggerID;
+ }
+
+ /**
+ * Returns the trigger specified by its commandTrigger value
+ *
+ * @param string $name
+ * @return CommandTrigger
+ */
+ public static function getTriggerByName($name) {
+ $sql = "SELECT *
+ FROM chat".WCF_N."_command_trigger
+ WHERE commandTrigger = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ $name ]);
+ $row = $statement->fetchArray();
+ if (!$row) $row = [];
+
+ return new self(null, $row);
+ }
+}
diff --git a/files/lib/data/command/CommandTriggerAction.class.php b/files/lib/data/command/CommandTriggerAction.class.php
new file mode 100644
index 0000000..0bfcd34
--- /dev/null
+++ b/files/lib/data/command/CommandTriggerAction.class.php
@@ -0,0 +1,30 @@
+reset();
+ }
+}
diff --git a/files/lib/data/command/CommandTriggerList.class.php b/files/lib/data/command/CommandTriggerList.class.php
new file mode 100644
index 0000000..4381635
--- /dev/null
+++ b/files/lib/data/command/CommandTriggerList.class.php
@@ -0,0 +1,20 @@
+data['payload'] = @unserialize($this->data['payload']);
+ if (!is_array($this->data['payload'])) {
+ $this->data['payload'] = [ ];
+ }
+ }
+
+ /**
+ * Returns whether this message already is inside the log.
+ *
+ * @return boolean
+ */
+ public function isInLog() {
+ return $this->time < (TIME_NOW - CHAT_ARCHIVE_AFTER);
+ }
+
+ /**
+ * Returns the message type object of this message.
+ *
+ * @return \wcf\data\object\type\ObjectType
+ */
+ public function getMessageType() {
+ return \wcf\data\object\type\ObjectTypeCache::getInstance()->getObjectType($this->objectTypeID);
+ }
+
+ /**
+ * Returns the chat room that contains this message.
+ *
+ * @return \chat\data\room\Room
+ */
+ public function getRoom() {
+ return \chat\data\room\RoomCache::getInstance()->getRoom($this->roomID);
+ }
+}
diff --git a/files/lib/data/message/MessageAction.class.php b/files/lib/data/message/MessageAction.class.php
new file mode 100644
index 0000000..18c918b
--- /dev/null
+++ b/files/lib/data/message/MessageAction.class.php
@@ -0,0 +1,307 @@
+parameters['updateTimestamp']) && $this->parameters['updateTimestamp']) {
+ $sql = "UPDATE chat".WCF_N."_room_to_user SET lastPush = ? WHERE roomID = ? AND userID = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ TIME_NOW, $message->roomID, $message->userID ]);
+ }
+ if (isset($this->parameters['grantPoints']) && $this->parameters['grantPoints']) {
+ UserActivityPointHandler::getInstance()->fireEvent('be.bastelstu.chat.activityPointEvent.message', $message->messageID, $message->userID);
+ }
+
+ $pushHandler = \wcf\system\push\PushHandler::getInstance();
+ if ($pushHandler->isEnabled() && in_array('target:channels', $pushHandler->getFeatureFlags())) {
+ $fastSelect = $message->getMessageType()->getProcessor()->supportsFastSelect();
+ if ($fastSelect) {
+ $target = [ 'channels' => [ 'be.bastelstu.chat.room-'.$message->roomID ] ];
+ }
+ else {
+ $target = [ 'channels' => [ 'be.bastelstu.chat' ] ];
+ }
+ $pushHandler->sendMessage([ 'message' => 'be.bastelstu.chat.message'
+ , 'target' => $target
+ ]);
+ }
+
+ return $message;
+ }
+
+ /**
+ * Validates parameters and permissions.
+ */
+ public function validateTrash() {
+ // read objects
+ if (empty($this->objects)) {
+ $this->readObjects();
+
+ if (empty($this->objects)) {
+ throw new UserInputException('objectIDs');
+ }
+ }
+
+ foreach ($this->getObjects() as $message) {
+ if ($message->isDeleted) continue;
+
+ $messageType = $message->getMessageType()->getProcessor();
+ if (!($messageType instanceof \chat\system\message\type\IDeletableMessageType) || !$messageType->canDelete($message->getDecoratedObject())) {
+ throw new PermissionDeniedException();
+ }
+ }
+ }
+
+ /**
+ * Marks this message as deleted and creates a tombstone message.
+ *
+ * Note: Contrary to other applications there is no way to undelete a message.
+ */
+ public function trash() {
+ if (empty($this->objects)) {
+ $this->readObjects();
+ }
+
+ $data = [ 'isDeleted' => 1
+ ];
+
+ $objectTypeID = ObjectTypeCache::getInstance()->getObjectTypeIDByName('be.bastelstu.chat.messageType', 'be.bastelstu.chat.messageType.tombstone');
+ if (!$objectTypeID) {
+ throw new \LogicException('Missing object type');
+ }
+
+ WCF::getDB()->beginTransaction();
+ $objectAction = new static($this->getObjects(), 'update', [ 'data' => $data ]);
+ $objectAction->executeAction();
+ foreach ($this->getObjects() as $message) {
+ if ($message->isDeleted) continue;
+
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $message->roomID
+ , 'userID' => null
+ , 'username' => ''
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'messageID' => $message->messageID ])
+ ]
+ ]
+ )
+ )->executeAction();
+ }
+ WCF::getDB()->commitTransaction();
+ }
+
+ /**
+ * Prunes chat messages older than chat_log_archivetime days.
+ */
+ public function prune() {
+ // Check whether pruning is disabled.
+ if (!CHAT_LOG_ARCHIVETIME) return;
+
+ $sql = "SELECT messageID
+ FROM chat".WCF_N."_message
+ WHERE time < ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ TIME_NOW - CHAT_LOG_ARCHIVETIME * 86400 ]);
+ $messageIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
+
+ return call_user_func([$this->className, 'deleteAll'], $messageIDs);
+ }
+
+ /**
+ * Validates parameters and permissions.
+ */
+ public function validatePull() {
+ $this->readString('sessionID', true);
+ if ($this->parameters['sessionID']) {
+ $this->parameters['sessionID'] = pack('H*', str_replace('-', '', $this->parameters['sessionID']));
+ }
+
+ $this->readInteger('roomID');
+ $this->readBoolean('inLog', true);
+
+ $room = RoomCache::getInstance()->getRoom($this->parameters['roomID']);
+ if ($room === null) throw new UserInputException('roomID');
+ if (!$room->canSee($user = null, $reason)) throw $reason;
+ $user = new \chat\data\user\User(WCF::getUser());
+ if (!$this->parameters['inLog'] && !$user->isInRoom($room)) throw new PermissionDeniedException();
+ if ($this->parameters['inLog'] && !$room->canSeeLog(null, $reason)) throw $reason;
+
+ $this->readInteger('from', true);
+ $this->readInteger('to', true);
+
+ // One may not pass both 'from' and 'to'
+ if ($this->parameters['from'] && $this->parameters['to']) {
+ throw new UserInputException();
+ }
+ }
+
+ /**
+ * Pulls messages for the given room.
+ */
+ public function pull() {
+ $room = RoomCache::getInstance()->getRoom($this->parameters['roomID']);
+ if ($room === null) throw new UserInputException('roomID');
+
+ if (($sessionID = $this->parameters['sessionID'])) {
+ if (strlen($sessionID) !== 16) throw new UserInputException('sessionID');
+
+ (new \chat\data\user\UserAction([], 'clearDeadSessions'))->executeAction();
+
+ WCF::getDB()->beginTransaction();
+ // update timestamp
+ $sql = "UPDATE chat".WCF_N."_room_to_user
+ SET lastPull = ?
+ WHERE roomID = ?
+ AND userID = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ TIME_NOW
+ , $room->roomID
+ , WCF::getUser()->userID
+ ]);
+
+ $sql = "UPDATE chat".WCF_N."_session
+ SET lastRequest = ?
+ WHERE roomID = ?
+ AND userID = ?
+ AND sessionID = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ TIME_NOW
+ , $room->roomID
+ , WCF::getUser()->userID
+ , $sessionID
+ ]);
+ WCF::getDB()->commitTransaction();
+ }
+
+ // Determine message types supporting fast select
+ $objectTypes = \wcf\data\object\type\ObjectTypeCache::getInstance()->getObjectTypes('be.bastelstu.chat.messageType');
+ $fastSelect = array_map(function ($item) {
+ return $item->objectTypeID;
+ }, array_filter($objectTypes, function ($item) {
+ return $item->getProcessor()->supportsFastSelect();
+ }));
+
+ // Build fast select filter
+ $condition = new \wcf\system\database\util\PreparedStatementConditionBuilder();
+ $condition->add('((roomID = ? AND objectTypeID IN (?)) OR objectTypeID NOT IN (?))', [ $room->roomID, $fastSelect, $fastSelect ]);
+
+ $sortOrder = 'DESC';
+ // Add offset
+ if ($this->parameters['from']) {
+ $condition->add('messageID >= ?', [ $this->parameters['from'] ]);
+ $sortOrder = 'ASC';
+ }
+ if ($this->parameters['to']) {
+ $condition->add('messageID <= ?', [ $this->parameters['to'] ]);
+ }
+
+ $sql = "SELECT messageID
+ FROM chat".WCF_N."_message
+ ".$condition."
+ ORDER BY messageID ".$sortOrder;
+ $statement = WCF::getDB()->prepareStatement($sql, 20);
+ $statement->execute($condition->getParameters());
+ $messageIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
+
+ $objectList = new MessageList();
+ $objectList->setObjectIDs($messageIDs);
+ $objectList->readObjects();
+ $objects = $objectList->getObjects();
+
+ $canSeeLog = $room->canSeeLog();
+ $messages = array_map(function (Message $item) use ($room) {
+ return new ViewableMessage($item, $room);
+ }, array_filter($objects, function (Message $message) use ($canSeeLog, $room) {
+ if ($this->parameters['inLog'] || $message->isInLog()) {
+ return $canSeeLog && $message->getMessageType()->getProcessor()->canSeeInLog($message, $room);
+ }
+ else {
+ return $message->getMessageType()->getProcessor()->canSee($message, $room);
+ }
+ }));
+
+ $embeddedObjectMessageIDs = array_map(function ($message) {
+ return $message->messageID;
+ }, array_filter($messages, function ($message) {
+ return $message->hasEmbeddedObjects;
+ }));
+
+ if (!empty($embeddedObjectMessageIDs)) {
+ // load embedded objects
+ \wcf\system\message\embedded\object\MessageEmbeddedObjectManager::getInstance()->loadObjects('be.bastelstu.chat.message', $embeddedObjectMessageIDs);
+ }
+
+ return [ 'messages' => $messages
+ , 'from' => $this->parameters['from'] ?: (!empty($objects) ? reset($objects)->messageID : $this->parameters['to'] + 1)
+ , 'to' => $this->parameters['to'] ?: (!empty($objects) ? end($objects)->messageID : $this->parameters['from'] - 1)
+ ];
+ }
+
+ /**
+ * Validates parameters and permissions.
+ */
+ public function validatePush() {
+ $this->readInteger('roomID');
+
+ $room = RoomCache::getInstance()->getRoom($this->parameters['roomID']);
+ if ($room === null) throw new UserInputException('roomID');
+ if (!$room->canSee($user = null, $reason)) throw $reason;
+ $user = new \chat\data\user\User(WCF::getUser());
+ if (!$user->isInRoom($room)) throw new PermissionDeniedException();
+
+ $this->readInteger('commandID');
+ $command = CommandCache::getInstance()->getCommand($this->parameters['commandID']);
+ if ($command === null) throw new UserInputException('commandID');
+ if (!$command->hasTriggers()) {
+ if (!$command->getProcessor()->allowWithoutTrigger()) {
+ throw new UserInputException('commandID');
+ }
+ }
+
+ $this->readJSON('parameters', true);
+ }
+
+ /**
+ * Pushes a new message into the given room.
+ */
+ public function push() {
+ $room = RoomCache::getInstance()->getRoom($this->parameters['roomID']);
+ if ($room === null) throw new UserInputException('roomID');
+
+ $command = CommandCache::getInstance()->getCommand($this->parameters['commandID']);
+ if ($command === null) throw new UserInputException('commandID');
+
+ $processor = $command->getProcessor();
+ $processor->validate($this->parameters['parameters'], $room);
+ $processor->execute($this->parameters['parameters'], $room);
+ }
+}
diff --git a/files/lib/data/message/MessageEditor.class.php b/files/lib/data/message/MessageEditor.class.php
new file mode 100644
index 0000000..9cc429f
--- /dev/null
+++ b/files/lib/data/message/MessageEditor.class.php
@@ -0,0 +1,25 @@
+room = $room;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function jsonSerialize() {
+ $link = LinkHandler::getInstance()->getLink('Log', [ 'application' => 'chat'
+ , 'messageid' => $this->messageID
+ , 'object' => $this->room
+ ]);
+
+ if ($this->isDeleted) {
+ $payload = false;
+ $objectType = 'be.bastelstu.chat.messageType.tombstone';
+ }
+ else {
+ $payload = $this->getMessageType()->getProcessor()->getPayload($this->getDecoratedObject());
+ $objectType = $this->getMessageType()->objectType;
+ }
+
+ return [ 'messageID' => $this->messageID
+ , 'userID' => $this->userID
+ , 'username' => $this->username
+ , 'time' => $this->time
+ , 'payload' => $payload
+ , 'objectType' => $objectType
+ , 'link' => $link
+ , 'isIgnored' => WCF::getUserProfileHandler()->isIgnoredUser($this->userID)
+ , 'isDeleted' => (bool) $this->isDeleted
+ ];
+ }
+}
diff --git a/files/lib/data/room/Room.class.php b/files/lib/data/room/Room.class.php
new file mode 100644
index 0000000..f1ff514
--- /dev/null
+++ b/files/lib/data/room/Room.class.php
@@ -0,0 +1,262 @@
+getTitle();
+ }
+
+ /**
+ * Returns whether the given user can see at least
+ * one chat room. If no user is given the current user
+ * should be assumed
+ *
+ * @param \wcf\data\user\UserProfile $user
+ * @return boolean
+ */
+ public static function canSeeAny(\wcf\data\user\UserProfile $user = null) {
+ $rooms = RoomCache::getInstance()->getRooms();
+ foreach ($rooms as $room) {
+ if ($room->canSee($user)) return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns whether the given user can see this room.
+ * If no user is given the current user should be assumed.
+ *
+ * @param \wcf\data\user\UserProfile $user
+ * @return boolean
+ */
+ public function canSee(\wcf\data\user\UserProfile $user = null, \Exception &$reason = null) {
+ static $cache = [ ];
+ if ($user === null) $user = new \wcf\data\user\UserProfile(WCF::getUser());
+
+ if (!isset($cache[$this->roomID])) $cache[$this->roomID] = [];
+ if (array_key_exists($user->userID, $cache[$this->roomID])) {
+ return ($reason = $cache[$this->roomID][$user->userID]) === null;
+ }
+
+ if (!$user->userID) {
+ $reason = new PermissionDeniedException();
+ return ($cache[$this->roomID][$user->userID] = $reason) === null;
+ }
+
+ $result = null;
+ if (!PermissionHandler::get($user)->getPermission($this, 'user.canSee')) {
+ $result = new PermissionDeniedException();
+ }
+
+ $parameters = [ 'user' => $user
+ , 'result' => $result
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+ $reason = $parameters['result'];
+
+ if (!($reason === null || $reason instanceof \Exception || $reason instanceof \Throwable)) {
+ throw new \DomainException('Result of canSee must be a \Throwable or null.');
+ }
+
+ return ($cache[$this->roomID][$user->userID] = $reason) === null;
+ }
+
+ /**
+ * Returns whether the given user can see the log of this room.
+ * If no user is given the current user should be assumed.
+ *
+ * @param \wcf\data\user\UserProfile $user
+ * @return boolean
+ */
+ public function canSeeLog(\wcf\data\user\UserProfile $user = null, \Exception &$reason = null) {
+ static $cache = [ ];
+ if ($user === null) $user = new \wcf\data\user\UserProfile(WCF::getUser());
+
+ if (!isset($cache[$this->roomID])) $cache[$this->roomID] = [];
+ if (array_key_exists($user->userID, $cache[$this->roomID])) {
+ return ($reason = $cache[$this->roomID][$user->userID]) === null;
+ }
+
+ $result = null;
+ if (!PermissionHandler::get($user)->getPermission($this, 'user.canSeeLog')) {
+ $result = new PermissionDeniedException();
+ }
+
+ $parameters = [ 'user' => $user
+ , 'result' => $result
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSeeLog', $parameters);
+ $reason = $parameters['result'];
+
+ if (!($reason === null || $reason instanceof \Exception || $reason instanceof \Throwable)) {
+ throw new \DomainException('Result of canSeeLog must be a \Throwable or null.');
+ }
+
+ return ($cache[$this->roomID][$user->userID] = $reason) === null;
+ }
+
+ /**
+ * Returns whether the given user can join this room.
+ * If no user is given the current user should be assumed.
+ *
+ * @param \wcf\data\user\UserProfile $user
+ * @return boolean
+ */
+ public function canJoin(\wcf\data\user\UserProfile $user = null, \Exception &$reason = null) {
+ static $cache = [ ];
+ if ($user === null) $user = new \wcf\data\user\UserProfile(WCF::getUser());
+
+ if (!isset($cache[$this->roomID])) $cache[$this->roomID] = [];
+ if (array_key_exists($user->userID, $cache[$this->roomID])) {
+ return ($reason = $cache[$this->roomID][$user->userID]) === null;
+ }
+
+ $parameters = [ 'user' => $user
+ , 'result' => null
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canJoin', $parameters);
+ $reason = $parameters['result'];
+
+ if (!($reason === null || $reason instanceof \Exception || $reason instanceof \Throwable)) {
+ throw new \DomainException('Result of canJoin must be a \Throwable or null.');
+ }
+
+ return ($cache[$this->roomID][$user->userID] = $reason) === null;
+ }
+
+ /**
+ * Returns whether the given user can write public messages in this room.
+ * If no user is given the current user should be assumed.
+ *
+ * @param \wcf\data\user\UserProfile $user
+ * @return boolean
+ */
+ public function canWritePublicly(\wcf\data\user\UserProfile $user = null, \Exception &$reason = null) {
+ static $cache = [ ];
+ if ($user === null) $user = new \wcf\data\user\UserProfile(WCF::getUser());
+
+ if (!isset($cache[$this->roomID])) $cache[$this->roomID] = [];
+ if (array_key_exists($user->userID, $cache[$this->roomID])) {
+ return ($reason = $cache[$this->roomID][$user->userID]) === null;
+ }
+
+ $result = null;
+ if (!PermissionHandler::get($user)->getPermission($this, 'user.canWrite')) {
+ $result = new PermissionDeniedException();
+ }
+
+ $parameters = [ 'user' => $user
+ , 'result' => $result
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canWritePublicly', $parameters);
+ $reason = $parameters['result'];
+
+ if (!($reason === null || $reason instanceof \Exception || $reason instanceof \Throwable)) {
+ throw new \DomainException('Result of canWritePublicly must be a \Throwable or null.');
+ }
+
+ return ($cache[$this->roomID][$user->userID] = $reason) === null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getTitle() {
+ return WCF::getLanguage()->get($this->title);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getTopic() {
+ $topic = StringUtil::trim(WCF::getLanguage()->get($this->topic));
+
+ if (!$this->topicUseHtml) {
+ $topic = StringUtil::encodeHTML($topic);
+ }
+
+ return $topic;
+ }
+
+ /**
+ * Returns an array of users in this room.
+ */
+ public function getUsers() {
+ if (self::$userToRoom === null) {
+ $sql = "SELECT r2u.userID, r2u.roomID
+ FROM chat".WCF_N."_room_to_user r2u
+ INNER JOIN wcf".WCF_N."_user u
+ ON r2u.userID = u.userID
+ WHERE r2u.active = ?
+ ORDER BY u.username ASC";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ 1 ]);
+ self::$userToRoom = $statement->fetchMap('roomID', 'userID', false);
+
+ if (!empty(self::$userToRoom)) {
+ UserRuntimeCache::getInstance()->cacheObjectIDs(array_merge(...self::$userToRoom));
+ }
+ }
+
+ if (!isset(self::$userToRoom[$this->roomID])) return [ ];
+
+ return UserRuntimeCache::getInstance()->getObjects(self::$userToRoom[$this->roomID]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getLink() {
+ return LinkHandler::getInstance()->getLink('Room', [ 'application' => 'chat'
+ , 'object' => $this
+ , 'forceFrontend' => true
+ ]
+ );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function jsonSerialize() {
+ return [ 'title' => $this->getTitle()
+ , 'topic' => $this->getTopic()
+ , 'link' => $this->getLink()
+ ];
+ }
+}
diff --git a/files/lib/data/room/RoomAction.class.php b/files/lib/data/room/RoomAction.class.php
new file mode 100644
index 0000000..3d5b7c2
--- /dev/null
+++ b/files/lib/data/room/RoomAction.class.php
@@ -0,0 +1,376 @@
+parameters['user']);
+
+ $this->readString('sessionID');
+ $this->parameters['sessionID'] = pack('H*', str_replace('-', '', $this->parameters['sessionID']));
+
+ $this->readInteger('roomID');
+
+ $room = RoomCache::getInstance()->getRoom($this->parameters['roomID']);
+ if ($room === null) throw new UserInputException('roomID');
+ if (!$room->canSee($user = null, $reason)) throw $reason;
+ if (!$room->canJoin($user = null, $reason)) throw $reason;
+ }
+
+ /**
+ * Makes the given user join the current chat room.
+ */
+ public function join() {
+ $objectTypeID = ObjectTypeCache::getInstance()->getObjectTypeIDByName('be.bastelstu.chat.messageType', 'be.bastelstu.chat.messageType.join');
+ if (!$objectTypeID) throw new \LogicException('Missing object type');
+ // User cannot be set during an AJAX request, but may be set by Tim’s Chat itself.
+ if (!isset($this->parameters['user'])) $this->parameters['user'] = WCF::getUser();
+ $user = new ChatUser($this->parameters['user']);
+
+ // Check parameters
+ $room = RoomCache::getInstance()->getRoom($this->parameters['roomID']);
+ if ($room === null) throw new UserInputException('roomID');
+ $sessionID = $this->parameters['sessionID'];
+ if (strlen($sessionID) !== 16) throw new UserInputException('sessionID');
+
+ try {
+ // Create room_to_user mapping.
+ $sql = "INSERT INTO chat".WCF_N."_room_to_user (active, roomID, userID) VALUES (?, ?, ?)";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ 0, $room->roomID, $user->userID ]);
+ }
+ catch (\wcf\system\database\exception\DatabaseException $e) {
+ // Ignore if there already is a mapping.
+ if ((string) $e->getCode() !== '23000') throw $e;
+ }
+
+ try {
+ $sql = "INSERT INTO chat".WCF_N."_session (roomID, userID, sessionID, lastRequest) VALUES (?, ?, ?, ?)";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ $room->roomID, $user->userID, $sessionID, TIME_NOW ]);
+ }
+ catch (\wcf\system\database\exception\DatabaseException $e) {
+ if ((string) $e->getCode() !== '23000') throw $e;
+
+ throw new UserInputException('sessionID');
+ }
+
+ $markAsBack = function () use ($user, $room) {
+ $userProfile = new \wcf\data\user\UserProfile($user->getDecoratedObject());
+ $package = \wcf\data\package\PackageCache::getInstance()->getPackageByIdentifier('be.bastelstu.chat');
+ $command = \chat\data\command\CommandCache::getInstance()->getCommandByPackageAndIdentifier($package, 'back');
+ $processor = $command->getProcessor();
+ $processor->execute([ ], $room, $userProfile);
+ };
+
+ if ($user->chatAway !== null) {
+ $markAsBack();
+ }
+
+ // Attempt to mark the user as active in the room.
+ $sql = "UPDATE chat".WCF_N."_room_to_user SET active = ? WHERE roomID = ? AND userID = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ 1, $room->roomID, $user->userID ]);
+ if ($statement->getAffectedRows() === 0) {
+ // The User already is inside the room: Nothing to do here.
+ return;
+ }
+
+ // Update lastPull. This must not be merged into the above query, because of the 'getAffectedRows' check.
+ $sql = "UPDATE chat".WCF_N."_room_to_user SET lastPull = ? WHERE roomID = ? AND userID = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ TIME_NOW, $room->roomID, $user->userID ]);
+
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ ])
+ ]
+ ]
+ )
+ )->executeAction();
+
+ UserActivityPointHandler::getInstance()->fireEvent('be.bastelstu.chat.activityPointEvent.join', 0, $user->userID);
+ $pushHandler = \wcf\system\push\PushHandler::getInstance();
+ $pushHandler->sendMessage([ 'message' => 'be.bastelstu.chat.join'
+ , 'target' => 'registered'
+ ]);
+ }
+
+ /**
+ * Validates parameters and permissions.
+ */
+ public function validateLeave() {
+ unset($this->parameters['user']);
+
+ $this->readString('sessionID');
+ $this->parameters['sessionID'] = pack('H*', str_replace('-', '', $this->parameters['sessionID']));
+
+ $this->readInteger('roomID');
+ $room = RoomCache::getInstance()->getRoom($this->parameters['roomID']);
+ if ($room === null) throw new UserInputException('roomID');
+ // Do not check permissions: If the user is not inside the room nothing happens, if he is it
+ // may lead to a faster eviction of the user.
+ }
+
+ /**
+ * Makes the given user leave the current chat room.
+ */
+ public function leave() {
+ $objectTypeID = ObjectTypeCache::getInstance()->getObjectTypeIDByName('be.bastelstu.chat.messageType', 'be.bastelstu.chat.messageType.leave');
+ if ($objectTypeID) {
+ // User cannot be set during an AJAX request, but may be set by Tim’s Chat itself.
+ if (!isset($this->parameters['user'])) $this->parameters['user'] = WCF::getUser();
+ $user = new ChatUser($this->parameters['user']);
+
+ $room = RoomCache::getInstance()->getRoom($this->parameters['roomID']);
+ if ($room === null) throw new UserInputException('roomID');
+
+ $sessionID = null;
+ if (isset($this->parameters['sessionID'])) {
+ $sessionID = $this->parameters['sessionID'];
+ if (strlen($sessionID) !== 16) throw new UserInputException('sessionID');
+ }
+
+ // Delete session.
+ $condition = new \wcf\system\database\util\PreparedStatementConditionBuilder();
+ $condition->add('roomID = ?', [ $room->roomID ]);
+ $condition->add('userID = ?', [ $user->userID ]);
+ if ($sessionID !== null) {
+ $condition->add('sessionID = ?', [ $sessionID ]);
+ }
+ $sql = "DELETE FROM chat".WCF_N."_session
+ ".$condition;
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute($condition->getParameters());
+ if ($statement->getAffectedRows() === 0) {
+ throw new UserInputException('sessionID');
+ }
+
+ try {
+ $commited = false;
+ WCF::getDB()->beginTransaction();
+
+ // Check whether we deleted the last session.
+ $sql = "SELECT COUNT(*)
+ FROM chat".WCF_N."_session
+ WHERE roomID = ?
+ AND userID = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ $room->roomID, $user->userID ]);
+
+ // We did not: Nothing to do here.
+ if ($statement->fetchColumn()) return;
+
+ // Mark the user as inactive.
+ $sql = "UPDATE chat".WCF_N."_room_to_user SET active = ? WHERE roomID = ? AND userID = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ 0, $room->roomID, $user->userID ]);
+ if ($statement->getAffectedRows() === 0) throw new \LogicException('Unreachable');
+
+ WCF::getDB()->commitTransaction();
+ $commited = true;
+ }
+ finally {
+ if (!$commited) WCF::getDB()->rollBackTransaction();
+ }
+
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ ])
+ ]
+ ]
+ )
+ )->executeAction();
+
+ $pushHandler = \wcf\system\push\PushHandler::getInstance();
+ $pushHandler->sendMessage([ 'message' => 'be.bastelstu.chat.leave'
+ , 'target' => 'registered'
+ ]);
+ }
+ else {
+ throw new \LogicException('Missing object type');
+ }
+ }
+
+ /**
+ * Validates parameters and permissions.
+ */
+ public function validateGetUsers() {
+ if (empty($this->getObjects())) {
+ $this->readObjects();
+ }
+ if (count($this->getObjects()) !== 1) {
+ throw new UserInputException('objectIDs');
+ }
+
+ $room = $this->getObjects()[0];
+
+ $user = new ChatUser(WCF::getUser());
+ if (!$user->isInRoom($room->getDecoratedObject())) throw new PermissionDeniedException();
+ }
+
+ /**
+ * Returns the userIDs of the users in this room.
+ */
+ public function getUsers() {
+ if (empty($this->getObjects())) {
+ $this->readObjects();
+ }
+ if (count($this->getObjects()) !== 1) {
+ throw new UserInputException('objectIDs');
+ }
+
+ $room = $this->getObjects()[0];
+
+ $users = (new \chat\data\user\UserAction([ ], 'getUsersByID', [
+ 'userIDs' => array_keys($room->getUsers())
+ ]))->executeAction()['returnValues'];
+
+ $users = array_map(function (array $user) use ($room) {
+ $userProfile = UserProfileRuntimeCache::getInstance()->getObject($user['userID']);
+ if (!isset($user['permissions'])) $user['permissions'] = [];
+ $user['permissions']['canWritePublicly'] = $room->canWritePublicly($userProfile);
+
+ return $user;
+ }, $users);
+
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'getUsers', $users);
+
+ return $users;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function validateUpdatePosition() {
+ // validate permissions
+ if (is_array($this->permissionsUpdate) && !empty($this->permissionsUpdate)) {
+ WCF::getSession()->checkPermissions($this->permissionsUpdate);
+ }
+ else {
+ throw new PermissionDeniedException();
+ }
+
+ $this->readIntegerArray('structure', false, 'data');
+
+ $roomList = new RoomList();
+ $roomList->readObjects();
+
+ foreach ($this->parameters['data']['structure'][0] as $roomID) {
+ $room = $roomList->search($roomID);
+ if ($room === null) throw new UserInputException('structure');
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function updatePosition() {
+ $roomList = new RoomList();
+ $roomList->readObjects();
+
+ $i = 0;
+ WCF::getDB()->beginTransaction();
+ foreach ($this->parameters['data']['structure'][0] as $roomID) {
+ $room = $roomList->search($roomID);
+ if ($room === null) continue;
+
+ $editor = new RoomEditor($room);
+ $editor->update([ 'position' => $i++ ]);
+ }
+ WCF::getDB()->commitTransaction();
+ }
+
+ /**
+ * Validates permissions.
+ */
+ public function validateGetBoxRoomList() {
+ if (!\chat\data\room\Room::canSeeAny()) throw new \wcf\system\exception\PermissionDeniedException();
+
+ $this->readBoolean('isSidebar', true);
+ $this->readBoolean('skipEmptyRooms', true);
+ $this->readInteger('activeRoomID', true);
+
+ unset($this->parameters['boxController']);
+ $this->readInteger('boxID', true);
+ if ($this->parameters['boxID']) {
+ $box = new \wcf\data\box\Box($this->parameters['boxID']);
+ if ($box->boxID) {
+ $this->parameters['boxController'] = $box->getController();
+ if ($this->parameters['boxController'] instanceof \chat\system\box\RoomListBoxController) {
+ // all checks passed, end validation; otherwise throw the exception below
+ return;
+ }
+ }
+
+ throw new UserInputException('boxID');
+ }
+ }
+
+ /**
+ * Returns dashboard roomlist.
+ */
+ public function getBoxRoomList() {
+ if (isset($this->parameters['boxController'])) {
+ $this->parameters['boxController']->setActiveRoomID($this->parameters['activeRoomID']);
+
+ return [ 'template' => $this->parameters['boxController']->getContent() ];
+ }
+
+ // Fetch all rooms, the templates have filtering in place
+ $rooms = RoomCache::getInstance()->getRooms();
+
+ $template = 'boxRoomList'.($this->parameters['isSidebar'] ? 'Sidebar' : '');
+
+ \wcf\system\WCF::getTPL()->assign([ 'boxRoomList' => $rooms
+ , 'skipEmptyRooms' => $this->parameters['skipEmptyRooms']
+ , 'activeRoomID' => $this->parameters['activeRoomID']
+ ]);
+
+ return [ 'template' => \wcf\system\WCF::getTPL()->fetch($template, 'chat') ];
+ }
+}
diff --git a/files/lib/data/room/RoomCache.class.php b/files/lib/data/room/RoomCache.class.php
new file mode 100644
index 0000000..3ed2888
--- /dev/null
+++ b/files/lib/data/room/RoomCache.class.php
@@ -0,0 +1,66 @@
+rooms = \chat\system\cache\builder\RoomCacheBuilder::getInstance()->getData();
+ }
+
+ /**
+ * Returns a specific room.
+ *
+ * @param integer $roomID
+ * @return Room
+ */
+ public function getRoom($roomID) {
+ if (isset($this->rooms[$roomID])) {
+ return $this->rooms[$roomID];
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns all rooms.
+ *
+ * @return Room[]
+ */
+ public function getRooms() {
+ return $this->rooms;
+ }
+}
diff --git a/files/lib/data/room/RoomEditor.class.php b/files/lib/data/room/RoomEditor.class.php
new file mode 100644
index 0000000..9701ff2
--- /dev/null
+++ b/files/lib/data/room/RoomEditor.class.php
@@ -0,0 +1,33 @@
+reset();
+ \chat\system\permission\PermissionHandler::resetCache();
+ }
+}
diff --git a/files/lib/data/room/RoomList.class.php b/files/lib/data/room/RoomList.class.php
new file mode 100644
index 0000000..b26b9c8
--- /dev/null
+++ b/files/lib/data/room/RoomList.class.php
@@ -0,0 +1,25 @@
+getConditionBuilder()->add('(expires IS NULL OR expires > ?)', [ TIME_NOW ]);
+ $suspensionList->getConditionBuilder()->add('revoked IS NULL');
+ $suspensionList->getConditionBuilder()->add('userID = ?', [ $user->userID ]);
+ $suspensionList->getConditionBuilder()->add('objectTypeID = ?', [ $objectTypeID ]);
+ $suspensionList->getConditionBuilder()->add('(roomID IS NULL OR roomID = ?)', [ $room->roomID ]);
+
+ $suspensionList->readObjects();
+
+ return array_filter($suspensionList->getObjects(), function (Suspension $suspension) {
+ return $suspension->isActive();
+ });
+ }
+
+ /**
+ * Returns the suspension object type of this message.
+ *
+ * @return \wcf\data\object\type\ObjectType
+ */
+ public function getSuspensionType() {
+ return \wcf\data\object\type\ObjectTypeCache::getInstance()->getObjectType($this->objectTypeID);
+ }
+
+ /**
+ * Returns whether this suspension still is in effect.
+ *
+ * @return boolean
+ */
+ public function isActive() {
+ if ($this->revoked !== null) return false;
+ if (!$this->getSuspensionType()->getProcessor()->hasEffect($this)) return false;
+
+ if ($this->expires === null) return true;
+
+ return $this->expires > TIME_NOW;
+ }
+
+ /**
+ * Returns the chat room this suspension is in effect.
+ * Returns null if this is a global suspension.
+ *
+ * @return \chat\data\room\Room
+ */
+ public function getRoom() {
+ if ($this->roomID === null) {
+ return null;
+ }
+
+ return \chat\data\room\RoomCache::getInstance()->getRoom($this->roomID);
+ }
+
+ /**
+ * Returns the user that is affected by this suspension.
+ *
+ * @return \wcf\data\user\User
+ */
+ public function getUser() {
+ return \wcf\system\cache\runtime\UserRuntimeCache::getInstance()->getObject($this->userID);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function jsonSerialize() {
+ return [ 'userID' => $this->userID
+ , 'username' => $this->getUser()->username
+ , 'roomID' => $this->roomID
+ , 'time' => $this->time
+ , 'expires' => $this->expires
+ , 'reason' => $this->reason
+ , 'objectType' => $this->getSuspensionType()->objectType
+ , 'judgeID' => $this->judgeID
+ , 'judge' => $this->judge
+ ];
+ }
+}
diff --git a/files/lib/data/suspension/SuspensionAction.class.php b/files/lib/data/suspension/SuspensionAction.class.php
new file mode 100644
index 0000000..fedc9a6
--- /dev/null
+++ b/files/lib/data/suspension/SuspensionAction.class.php
@@ -0,0 +1,68 @@
+objects)) {
+ $this->readObjects();
+
+ if (empty($this->objects)) {
+ throw new UserInputException('objectIDs');
+ }
+ }
+
+ unset($this->parameters['revoker']);
+
+ WCF::getSession()->checkPermissions([ 'admin.chat.canManageSuspensions' ]);
+
+ foreach ($this->getObjects() as $object) {
+ if (!$object->isActive()) throw new UserInputException('objectIDs', 'nonActive');
+ }
+ }
+
+ /**
+ * Revokes the suspensions
+ */
+ public function revoke() {
+ if (empty($this->objects)) {
+ $this->readObjects();
+ }
+
+ // User cannot be set during an AJAX request, but may be set by Tim’s Chat itself.
+ if (!isset($this->parameters['revoker'])) $this->parameters['revoker'] = WCF::getUser();
+
+ $data = [ 'revoked' => TIME_NOW
+ , 'revokerID' => $this->parameters['revoker']->userID
+ , 'revoker' => $this->parameters['revoker']->username
+ ];
+
+ $objectAction = new static($this->getObjects(), 'update', [ 'data' => $data ]);
+ $objectAction->executeAction();
+ }
+}
diff --git a/files/lib/data/suspension/SuspensionEditor.class.php b/files/lib/data/suspension/SuspensionEditor.class.php
new file mode 100644
index 0000000..174f0f0
--- /dev/null
+++ b/files/lib/data/suspension/SuspensionEditor.class.php
@@ -0,0 +1,25 @@
+roomToUser === null || $skipCache) {
+ $sql = "SELECT *
+ FROM chat".WCF_N."_room_to_user
+ WHERE userID = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ $this->userID ]);
+ $this->roomToUser = [ ];
+ while (($row = $statement->fetchArray())) {
+ $this->roomToUser[$row['roomID']] = $row;
+ }
+ }
+
+ return $this->roomToUser;
+ }
+
+ /**
+ * Returns an array of Rooms this user is part of.
+ *
+ * @return \chat\data\room\Room[]
+ */
+ public function getRooms($skipCache = false) {
+ return array_map(function ($assoc) {
+ return \chat\data\room\RoomCache::getInstance()->getRoom($assoc['roomID']);
+ }, array_filter($this->getRoomAssociations($skipCache), function ($assoc) {
+ return $assoc['active'] === 1;
+ }));
+ }
+
+ /**
+ * Returns whether the user is in the given room.
+ *
+ * @param \chat\data\room\Room $room
+ * @return boolean
+ */
+ public function isInRoom(\chat\data\room\Room $room, $skipCache = false) {
+ $assoc = $this->getRoomAssociations($skipCache);
+
+ if (!isset($assoc[$room->roomID])) return false;
+ return $assoc[$room->roomID]['active'] === 1;
+ }
+
+ /**
+ * Returns (userID, roomID, sessionID) triples where the client died.
+ *
+ * @return mixed[][]
+ */
+ public static function getDeadSessions() {
+ $sql = "SELECT userID, roomID, sessionID
+ FROM chat".WCF_N."_session
+ WHERE lastRequest < ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ TIME_NOW - 60 * 3 ]);
+
+ return $statement->fetchAll(\PDO::FETCH_ASSOC);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function jsonSerialize() {
+ return [ 'userID' => $this->userID
+ , 'username' => $this->username
+ , 'link' => $this->getLink()
+ ];
+ }
+}
diff --git a/files/lib/data/user/UserAction.class.php b/files/lib/data/user/UserAction.class.php
new file mode 100644
index 0000000..5045c13
--- /dev/null
+++ b/files/lib/data/user/UserAction.class.php
@@ -0,0 +1,118 @@
+readIntegerArray('userIDs');
+ }
+
+ /**
+ * Returns information about the users identified by the given userIDs.
+ */
+ public function getUsersByID() {
+ $userList = UserProfileRuntimeCache::getInstance()->getObjects($this->parameters['userIDs']);
+
+ return array_map(function ($user) {
+ if (!$user) return null;
+
+ $payload = [ 'image16' => $user->getAvatar()->getImageTag(16)
+ , 'image24' => $user->getAvatar()->getImageTag(24)
+ , 'image32' => $user->getAvatar()->getImageTag(32)
+ , 'image48' => $user->getAvatar()->getImageTag(48)
+ , 'imageUrl' => $user->getAvatar()->getURL()
+ , 'link' => $user->getLink()
+ , 'anchor' => $user->getAnchorTag()
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'userTitle' => $user->getUserTitle()
+ , 'userRankClass' => $user->getRank() ? $user->getRank()->cssClassName : null
+ , 'formattedUsername' => $user->getFormattedUsername()
+ , 'away' => $user->chatAway
+ , 'color1' => $user->chatColor1
+ , 'color2' => $user->chatColor2
+ ];
+
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'getUsersByID', $payload);
+
+ return $payload;
+ }, $userList);
+ }
+
+ /**
+ * Clears dead clients.
+ */
+ public function clearDeadSessions() {
+ $sessions = User::getDeadSessions();
+ if (empty($sessions)) return;
+ $userIDs = array_map(function ($item) {
+ return $item['userID'];
+ }, $sessions);
+ $users = UserRuntimeCache::getInstance()->getObjects($userIDs);
+ foreach ($sessions as $session) {
+ $parameters = [ 'user' => $users[$session['userID']]
+ , 'roomID' => $session['roomID']
+ , 'sessionID' => $session['sessionID']
+ ];
+ try {
+ (new \chat\data\room\RoomAction([ ], 'leave', $parameters))->executeAction();
+ }
+ catch (UserInputException $e) {
+ // Probably some other request has been faster to remove this session, ignore
+ }
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function create() {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function update() {
+ throw new \BadMethodCallException();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function delete() {
+ throw new \BadMethodCallException();
+ }
+}
diff --git a/files/lib/data/user/UserList.class.php b/files/lib/data/user/UserList.class.php
new file mode 100644
index 0000000..363fda9
--- /dev/null
+++ b/files/lib/data/user/UserList.class.php
@@ -0,0 +1,25 @@
+roomID = intval($_GET['id']);
+ $this->room = \chat\data\room\RoomCache::getInstance()->getRoom($this->roomID);
+
+ if ($this->room === null) throw new IllegalLinkException();
+ if (!$this->room->canSee($user = null, $reason)) throw $reason;
+ if (!$this->room->canSeeLog($user = null, $reason)) throw $reason;
+
+ if (isset($_GET['messageid'])) $this->messageID = intval($_GET['messageid']);
+ if ($this->messageID) {
+ $this->message = new \chat\data\message\Message($this->messageID);
+ if (!$this->message->getMessageType()->getProcessor()->canSeeInLog($this->message, $this->room)) {
+ throw new PermissionDeniedException();
+ }
+ }
+
+ if (isset($_REQUEST['datetime'])) $this->datetime = strtotime($_REQUEST['datetime']);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function readData() {
+ parent::readData();
+
+ if ($this->datetime) {
+ // Determine message types supporting fast select
+ $objectTypes = \wcf\data\object\type\ObjectTypeCache::getInstance()->getObjectTypes('be.bastelstu.chat.messageType');
+ $fastSelect = array_map(function ($item) {
+ return $item->objectTypeID;
+ }, array_filter($objectTypes, function ($item) {
+ // TODO: Consider a method couldAppearInLog(): bool
+ return $item->getProcessor()->supportsFastSelect();
+ }));
+
+ $minimum = 0;
+ $loops = 0;
+ do {
+ // Build fast select filter
+ $condition = new \wcf\system\database\util\PreparedStatementConditionBuilder();
+ $condition->add('((roomID = ? AND objectTypeID IN (?)) OR objectTypeID NOT IN (?))', [ $this->room->roomID, $fastSelect, $fastSelect ]);
+ $condition->add('time >= ?', [ $this->datetime ]);
+ if ($minimum) {
+ $condition->add('messageID > ?', [ $minimum ]);
+ }
+
+ $sql = "SELECT messageID
+ FROM chat".WCF_N."_message
+ ".$condition."
+ ORDER BY messageID ASC";
+ $statement = WCF::getDB()->prepareStatement($sql, 20);
+ $statement->execute($condition->getParameters());
+ $messageIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
+
+ $objectList = new MessageList();
+ $objectList->setObjectIDs($messageIDs);
+ $objectList->readObjects();
+ $objects = $objectList->getObjects();
+ if (empty($objects)) {
+ // TODO: UserInputException?
+ throw new IllegalLinkException();
+ }
+
+ foreach ($objects as $message) {
+ if ($message->getMessageType()->getProcessor()->canSeeInLog($message, $this->room)) {
+ $parameters = [ 'application' => 'chat'
+ , 'messageid' => $message->messageID
+ , 'object' => $this->room
+ ];
+ \wcf\util\HeaderUtil::redirect(\wcf\system\request\LinkHandler::getInstance()->getLink('Log', $parameters));
+ exit;
+ }
+ $minimum = $message->messageID;
+ }
+ }
+ while (++$loops <= 3);
+
+ // Do a best guess redirect to an ID that is as near as possible
+ $parameters = [ 'application' => 'chat'
+ , 'messageid' => $minimum
+ , 'object' => $this->room
+ ];
+ \wcf\util\HeaderUtil::redirect(\wcf\system\request\LinkHandler::getInstance()->getLink('Log', $parameters));
+ exit;
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function assignVariables() {
+ parent::assignVariables();
+
+ PageLocationManager::getInstance()->addParentLocation('be.bastelstu.chat.Room', $this->room->roomID, $this->room);
+ WCF::getTPL()->assign([ 'room' => $this->room
+ , 'roomList' => \chat\data\room\RoomCache::getInstance()->getRooms()
+ , 'messageID' => $this->messageID
+ , 'message' => $this->message
+ , 'config' => $this->getConfig()
+ ]);
+ }
+}
diff --git a/files/lib/page/RoomListPage.class.php b/files/lib/page/RoomListPage.class.php
new file mode 100644
index 0000000..9e8ce0a
--- /dev/null
+++ b/files/lib/page/RoomListPage.class.php
@@ -0,0 +1,61 @@
+rooms = \chat\data\room\RoomCache::getInstance()->getRooms();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function assignVariables() {
+ parent::assignVariables();
+
+ WCF::getTPL()->assign([ 'rooms' => $this->rooms ]);
+ }
+}
diff --git a/files/lib/page/RoomPage.class.php b/files/lib/page/RoomPage.class.php
new file mode 100644
index 0000000..d6bbf2d
--- /dev/null
+++ b/files/lib/page/RoomPage.class.php
@@ -0,0 +1,109 @@
+roomID = intval($_GET['id']);
+ $this->room = \chat\data\room\RoomCache::getInstance()->getRoom($this->roomID);
+
+ if ($this->room === null) throw new IllegalLinkException();
+ if (!$this->room->canSee($user = null, $reason)) throw $reason;
+ if (!$this->room->canJoin($user = null, $reason)) throw $reason;
+
+ $this->canonicalURL = $this->room->getLink();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function checkPermissions() {
+ parent::checkPermissions();
+
+ $package = \wcf\data\package\PackageCache::getInstance()->getPackageByIdentifier('be.bastelstu.chat');
+ if (stripos($package->packageVersion, 'Alpha') !== false) {
+ $sql = "SELECT COUNT(*) FROM wcf".WCF_N."_user";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute();
+ $userCount = $statement->fetchSingleColumn();
+ if ((($userCount > 5 && !OFFLINE) || ($userCount > 30 && OFFLINE)) && sha1(WCF_UUID) !== '643a6b3af2a6ea3d393c4d8371e75d7d1b66e0d0') {
+ throw new PermissionDeniedException("Do not use alpha versions of Tims Chat in production communities!");
+ }
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function readData() {
+ $sql = "SELECT 1";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute();
+ if ($statement->fetchSingleColumn() !== 1) {
+ throw new NamedUserException('PHP must be configured to use the MySQLnd driver, instead of libmysqlclient.');
+ }
+
+ parent::readData();
+
+ $pushHandler = \wcf\system\push\PushHandler::getInstance();
+ $pushHandler->joinChannel('be.bastelstu.chat');
+ $pushHandler->joinChannel('be.bastelstu.chat.room-'.$this->room->roomID);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function assignVariables() {
+ parent::assignVariables();
+
+ WCF::getTPL()->assign([ 'room' => $this->room
+ , 'config' => $this->getConfig()
+ ]);
+ }
+}
diff --git a/files/lib/page/TConfiguredPage.class.php b/files/lib/page/TConfiguredPage.class.php
new file mode 100644
index 0000000..c33e681
--- /dev/null
+++ b/files/lib/page/TConfiguredPage.class.php
@@ -0,0 +1,60 @@
+getTriggers();
+
+ $commands = array_map(function (Command $item) {
+ $package = PackageCache::getInstance()->getPackage($item->packageID)->package;
+ return [ 'package' => $package
+ , 'identifier' => $item->identifier
+ , 'commandID' => $item->commandID
+ , 'module' => $item->getProcessor()->getJavaScriptModuleName()
+ , 'isAvailable' => $item->getProcessor()->isAvailable($this->room) && ($item->hasTriggers() || $item->getProcessor()->allowWithoutTrigger())
+ ];
+ }, CommandCache::getInstance()->getCommands());
+
+ $messageTypes = array_map(function ($item) {
+ return [ 'module' => $item->getProcessor()->getJavaScriptModuleName()
+ ];
+ }, ObjectTypeCache::getInstance()->getObjectTypes('be.bastelstu.chat.messageType'));
+
+ $config = [ 'clientVersion' => 1
+ , 'reloadTime' => (int) CHAT_RELOADTIME
+ , 'autoAwayTime' => (int) CHAT_AUTOAWAYTIME
+ , 'commands' => $commands
+ , 'triggers' => $triggers
+ , 'messageTypes' => $messageTypes
+ ];
+
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'config', $config);
+
+ return \wcf\util\JSON::encode($config);
+ }
+}
diff --git a/files/lib/system/CHATCore.class.php b/files/lib/system/CHATCore.class.php
new file mode 100644
index 0000000..e11d996
--- /dev/null
+++ b/files/lib/system/CHATCore.class.php
@@ -0,0 +1,38 @@
+setStaticController('chat', 'Log');
+ $route->setBuildSchema('/{controller}/{id}-{title}/{messageid}');
+ $route->setPattern('~^/?(?P[^/]+)/(?P\d+)(?:-(?P[^/]+))?/(?P\d+)~x');
+ $route->setRequiredComponents([ 'id' => '~^\d+$~'
+ , 'messageid' => '~^\d+$~'
+ ]);
+ $route->setMatchController(true);
+
+ \wcf\system\request\RouteHandler::getInstance()->addRoute($route);
+ }
+}
diff --git a/files/lib/system/box/RoomListBoxController.class.php b/files/lib/system/box/RoomListBoxController.class.php
new file mode 100644
index 0000000..f3f59fb
--- /dev/null
+++ b/files/lib/system/box/RoomListBoxController.class.php
@@ -0,0 +1,120 @@
+getActiveRequest();
+ if ($activeRequest && $activeRequest->getRequestObject() instanceof \chat\page\RoomPage) {
+ $this->activeRoomID = $activeRequest->getRequestObject()->room->roomID;
+ }
+ }
+
+ /**
+ * Sets the active room ID.
+ */
+ public function setActiveRoomID($activeRoomID) {
+ $this->activeRoomID = $activeRoomID;
+ }
+
+ /**
+ * Returns the active room ID.
+ *
+ * @return int
+ */
+ public function getActiveRoomID() {
+ return $this->activeRoomID;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hasLink() {
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getLink() {
+ return \wcf\system\request\LinkHandler::getInstance()->getLink('RoomList', [ 'application' => 'chat' ]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getObjectList() {
+ return new \chat\data\room\RoomList();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getTemplate() {
+ $templateName = 'boxRoomList';
+ if ($this->box->position === 'sidebarLeft' || $this->box->position === 'sidebarRight') {
+ $templateName = 'boxRoomListSidebar';
+ }
+
+ return WCF::getTPL()->fetch($templateName, 'chat', [ 'boxRoomList' => $this->objectList
+ , 'boxID' => $this->getBox()->boxID
+ , 'activeRoomID' => $this->activeRoomID ?: 0
+ ], true);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hasContent() {
+ if ($this->box->position === 'sidebarLeft' || $this->box->position === 'sidebarRight') {
+ parent::hasContent();
+
+ foreach ($this->objectList as $room) {
+ if ($room->canSee()) return true;
+ }
+
+ return false;
+ }
+ else {
+ return \chat\data\room\Room::canSeeAny();
+ }
+ }
+}
diff --git a/files/lib/system/cache/builder/CommandCacheBuilder.class.php b/files/lib/system/cache/builder/CommandCacheBuilder.class.php
new file mode 100644
index 0000000..f9262bb
--- /dev/null
+++ b/files/lib/system/cache/builder/CommandCacheBuilder.class.php
@@ -0,0 +1,52 @@
+ [ ]
+ , 'triggers' => [ ]
+ , 'packages' => [ ]
+ ];
+
+ $commandList = new \chat\data\command\CommandList();
+ $commandList->sqlOrderBy = 'command.commandID';
+ $commandList->readObjects();
+
+ $data['commands'] = $commandList->getObjects();
+
+ foreach ($data['commands'] as $command) {
+ if (!isset($data['packages'][$command->packageID])) $data['packages'][$command->packageID] = [ ];
+ $data['packages'][$command->packageID][$command->identifier] = $command;
+ }
+
+ $sql = "SELECT *
+ FROM chat".WCF_N."_command_trigger";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute();
+
+ $data['triggers'] = $statement->fetchMap('commandTrigger', 'commandID');
+
+ return $data;
+ }
+}
diff --git a/files/lib/system/cache/builder/PermissionCacheBuilder.class.php b/files/lib/system/cache/builder/PermissionCacheBuilder.class.php
new file mode 100644
index 0000000..5e2671f
--- /dev/null
+++ b/files/lib/system/cache/builder/PermissionCacheBuilder.class.php
@@ -0,0 +1,61 @@
+add('acl_option.objectTypeID = ?', [ ACLHandler::getInstance()->getObjectTypeID('be.bastelstu.chat.room') ]);
+ $conditionBuilder->add('option_to_group.groupID IN (?)', [ $parameters ]);
+ $sql = "SELECT option_to_group.objectID AS roomID,
+ option_to_group.optionValue,
+ acl_option.optionName AS permission
+ FROM wcf".WCF_N."_acl_option acl_option
+ INNER JOIN wcf".WCF_N."_acl_option_to_group option_to_group
+ ON option_to_group.optionID = acl_option.optionID
+ ".$conditionBuilder;
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute($conditionBuilder->getParameters());
+ while (($row = $statement->fetchArray())) {
+ if (!isset($data[$row['roomID']][$row['permission']])) {
+ $data[$row['roomID']][$row['permission']] = $row['optionValue'];
+ }
+ else {
+ $data[$row['roomID']][$row['permission']] = $row['optionValue'] || $data[$row['roomID']][$row['permission']];
+ }
+ }
+ }
+
+ return $data;
+ }
+}
diff --git a/files/lib/system/cache/builder/RoomCacheBuilder.class.php b/files/lib/system/cache/builder/RoomCacheBuilder.class.php
new file mode 100644
index 0000000..ba7c89e
--- /dev/null
+++ b/files/lib/system/cache/builder/RoomCacheBuilder.class.php
@@ -0,0 +1,31 @@
+sqlOrderBy = "room.position";
+ $roomList->readObjects();
+
+ return $roomList->getObjects();
+ }
+}
diff --git a/files/lib/system/cache/runtime/UserRuntimeCache.class.php b/files/lib/system/cache/runtime/UserRuntimeCache.class.php
new file mode 100644
index 0000000..b25aea1
--- /dev/null
+++ b/files/lib/system/cache/runtime/UserRuntimeCache.class.php
@@ -0,0 +1,25 @@
+getObjectTypeIDByName('be.bastelstu.chat.messageType', $objectType);
+
+ if (!$objectType) {
+ throw new \LogicException('Missing object type');
+ }
+
+ return $objectTypeID;
+ }
+
+ /**
+ * Ensures that the given parameter exists in the parameter array and
+ * throws otherwise.
+ *
+ * @param array $parameters
+ * @param string $key
+ * @return mixed The value.
+ */
+ public function assertParameter($parameters, $key) {
+ if (array_key_exists($key, $parameters)) {
+ return $parameters[$key];
+ }
+
+ throw new UserInputException('message');
+ }
+}
diff --git a/files/lib/system/command/AbstractInputProcessedCommand.class.php b/files/lib/system/command/AbstractInputProcessedCommand.class.php
new file mode 100644
index 0000000..8bbd004
--- /dev/null
+++ b/files/lib/system/command/AbstractInputProcessedCommand.class.php
@@ -0,0 +1,84 @@
+processor = new \wcf\system\html\input\HtmlInputProcessor();
+ $this->setDisallowedBBCodes();
+ }
+
+ private function setDisallowedBBCodes() {
+ BBCodeHandler::getInstance()->setDisallowedBBCodes(explode(',', WCF::getSession()->getPermission('user.chat.disallowedBBCodes')));
+ }
+
+ public function setText($text) {
+ if ($this->text === $text) return;
+
+ $this->text = $text;
+ $this->setDisallowedBBCodes();
+ $this->processor->process($text, 'be.bastelstu.chat.message', 0);
+ }
+
+ public function validateText() {
+ if ($this->processor->appearsToBeEmpty()) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('wcf.global.form.error.empty'));
+ }
+
+ $message = $this->processor->getTextContent();
+
+ // validate message length
+ if (mb_strlen($message) > CHAT_MAX_LENGTH) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('wcf.message.error.tooLong', [ 'maxTextLength' => CHAT_MAX_LENGTH ]));
+ }
+
+ // search for disallowed bbcodes
+ $this->setDisallowedBBCodes();
+ $disallowedBBCodes = $this->processor->validate();
+ if (!empty($disallowedBBCodes)) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('wcf.message.error.disallowedBBCodes', [ 'disallowedBBCodes' => $disallowedBBCodes ]));
+ }
+
+ // search for censored words
+ if (ENABLE_CENSORSHIP) {
+ $result = Censorship::getInstance()->test($message);
+ if ($result) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('wcf.message.error.censoredWordsFound', [ 'censoredWords' => $result ]));
+ }
+ }
+ }
+}
diff --git a/files/lib/system/command/AbstractSuspensionCommand.class.php b/files/lib/system/command/AbstractSuspensionCommand.class.php
new file mode 100644
index 0000000..e29780a
--- /dev/null
+++ b/files/lib/system/command/AbstractSuspensionCommand.class.php
@@ -0,0 +1,170 @@
+assertParameter($parameters, 'username');
+ $this->assertParameter($parameters, 'globally');
+ $this->assertParameter($parameters, 'duration');
+ $this->assertParameter($parameters, 'reason');
+
+ $this->assertUser($parameters['username']);
+ if ($parameters['duration'] !== null && $parameters['duration'] < TIME_NOW) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('chat.error.datePast'));
+ }
+ if (!empty($parameters['reason']) && mb_strlen($parameters['reason']) > 100) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('wcf.message.error.tooLong', [ 'maxTextLength' => 250 ]));
+ }
+ $this->checkPermissions($parameters, $room, $user);
+
+ $test = new Suspension(null, $this->getSuspensionData($parameters, $room, $user));
+ if (!$test->isActive()) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('chat.error.suspension.noEffect'));
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $data = $this->getSuspensionData($parameters, $room, $user);
+ $test = new Suspension(null, $data);
+ if (!$test->isActive()) {
+ return;
+ }
+
+ WCF::getDB()->beginTransaction();
+ $suspension = (new SuspensionAction([ ], 'create', [ 'data' => $data ]))->executeAction()['returnValues'];
+
+ $this->afterCreate($suspension, $parameters, $room, $user);
+ WCF::getDB()->commitTransaction();
+ }
+
+ /**
+ * Creates chat messages informing about the suspension.
+ *
+ * @param \chat\data\suspension\Suspension $suspension
+ * @param mixed[] $parameters
+ * @param \chat\data\room\Room $room
+ * @param \wcf\data\user\UserProfile $user
+ */
+ protected function afterCreate(Suspension $suspension, $parameters, Room $room, UserProfile $user) {
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.suspend');
+ $target = $suspension->getUser();
+
+ if ($suspension->getRoom() === null) {
+ $roomIDs = array_map(function (Room $room) use ($user) {
+ return $room->roomID;
+ }, (new \chat\data\user\User($target))->getRooms());
+ $roomIDs[] = $room->roomID;
+ }
+ else {
+ $roomIDs = [ $suspension->getRoom()->roomID ];
+ }
+
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'suspension' => $suspension
+ , 'roomIDs' => $roomIDs
+ , 'globally' => $this->isGlobally($parameters)
+ , 'target' => [ 'userID' => $target->userID
+ , 'username' => $target->username
+ ]
+ ])
+ ]
+ , 'updateTimestamp' => true
+ ]
+ )
+ )->executeAction();
+ }
+
+ /**
+ * Returns the database fields.
+ *
+ * @param mixed[] $parameters
+ * @param \chat\data\room\Room $room
+ * @param \wcf\data\user\UserProfile $user
+ * @return mixed[]
+ */
+ protected function getSuspensionData($parameters, Room $room, UserProfile $user = null) {
+ $target = $this->getUser($parameters['username']);
+ $globally = $this->isGlobally($parameters);
+ $expires = $parameters['duration'];
+ $reason = $parameters['reason'] ?: '';
+
+ $roomID = $globally ? null : $room->roomID;
+ $objectTypeID = ObjectTypeCache::getInstance()->getObjectTypeIDByName('be.bastelstu.chat.suspension', $this->getObjectTypeName());
+
+ return [ 'time' => TIME_NOW
+ , 'expires' => $expires
+ , 'roomID' => $roomID
+ , 'userID' => $target->userID
+ , 'objectTypeID' => $objectTypeID
+ , 'reason' => $reason
+ , 'judgeID' => $user->userID
+ , 'judge' => $user->username
+ ];
+ }
+
+ /**
+ * Returns whether a global suspension was requested.
+ *
+ * @param mixed[] $parameters
+ * @return boolean
+ */
+ protected function isGlobally($parameters) {
+ return $parameters['globally'] === true;
+ }
+}
diff --git a/files/lib/system/command/AbstractUnsuspensionCommand.class.php b/files/lib/system/command/AbstractUnsuspensionCommand.class.php
new file mode 100644
index 0000000..4bdb21f
--- /dev/null
+++ b/files/lib/system/command/AbstractUnsuspensionCommand.class.php
@@ -0,0 +1,162 @@
+assertParameter($parameters, 'username');
+ $this->assertParameter($parameters, 'globally');
+
+ $this->assertUser($parameters['username']);
+
+ $suspensions = $this->getSuspensionData($parameters, $room, $user);
+ if (empty($suspensions)) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('chat.error.suspension.remove.empty'));
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $suspensions = $this->getSuspensionData($parameters, $room, $user);
+
+ WCF::getDB()->beginTransaction();
+ (new SuspensionAction($suspensions, 'revoke', [ ]))->executeAction();
+ $this->afterCreate($suspensions, $parameters, $room, $user);
+ WCF::getDB()->commitTransaction();
+ }
+
+ /**
+ * Creates chat messages informing about the removed suspensions.
+ *
+ * @param \chat\data\suspension\Suspension[] $suspension
+ * @param mixed[] $parameters
+ * @param \chat\data\room\Room $room
+ * @param \wcf\data\user\UserProfile $user
+ */
+ protected function afterCreate($suspensions, $parameters, Room $room, UserProfile $user) {
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.unsuspend');
+ $target = $this->getUser($parameters['username']);
+ if ($this->isGlobally($parameters)) {
+ $roomIDs = array_map(function (Room $room) use ($user) {
+ return $room->roomID;
+ }, (new \chat\data\user\User($target))->getRooms());
+ $roomIDs[] = $room->roomID;
+ }
+ else {
+ $roomIDs = [ $room->roomID ];
+ }
+
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'objectType' => $this->getObjectTypeName()
+ , 'roomIDs' => $roomIDs
+ , 'globally' => $this->isGlobally($parameters)
+ , 'target' => [ 'userID' => $target->userID
+ , 'username' => $target->username
+ ]
+ ])
+ ]
+ , 'updateTimestamp' => true
+ ]
+ )
+ )->executeAction();
+ }
+
+ /**
+ * Returns the active suspensions.
+ *
+ * @param mixed[] $parameters
+ * @param \chat\data\room\Room $room
+ * @param \wcf\data\user\UserProfile $user
+ * @return mixed[]
+ */
+ protected function getSuspensionData($parameters, Room $room, UserProfile $user = null) {
+ $target = $this->getUser($parameters['username']);
+
+ $roomID = $this->isGlobally($parameters) ? null : $room->roomID;
+ $objectTypeID = ObjectTypeCache::getInstance()->getObjectTypeIDByName('be.bastelstu.chat.suspension', $this->getObjectTypeName());
+
+ $suspensionList = new SuspensionList();
+
+ $suspensionList->getConditionBuilder()->add('(expires IS NULL OR expires > ?)', [ TIME_NOW ]);
+ $suspensionList->getConditionBuilder()->add('revoked IS NULL');
+ $suspensionList->getConditionBuilder()->add('userID = ?', [ $target->userID ]);
+ $suspensionList->getConditionBuilder()->add('objectTypeID = ?', [ $objectTypeID ]);
+ if ($roomID === null) {
+ $suspensionList->getConditionBuilder()->add('roomID IS NULL');
+ }
+ else {
+ $suspensionList->getConditionBuilder()->add('roomID = ?', [ $room->roomID ]);
+ }
+
+ $suspensionList->readObjects();
+
+ return array_filter($suspensionList->getObjects(), function (Suspension $suspension) {
+ return $suspension->isActive();
+ });
+ }
+
+ /**
+ * Returns whether a global suspension removal was requested.
+ *
+ * @param mixed[] $parameters
+ * @return boolean
+ */
+ protected function isGlobally($parameters) {
+ return $parameters['globally'] === true;
+ }
+}
diff --git a/files/lib/system/command/AwayCommand.class.php b/files/lib/system/command/AwayCommand.class.php
new file mode 100644
index 0000000..6b69044
--- /dev/null
+++ b/files/lib/system/command/AwayCommand.class.php
@@ -0,0 +1,87 @@
+assertParameter($parameters, 'reason');
+
+ // search for censored words
+ if (ENABLE_CENSORSHIP) {
+ $result = Censorship::getInstance()->test($reason);
+ if ($result) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('wcf.message.error.censoredWordsFound', [ 'censoredWords' => $result ]));
+ }
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(WCF::getUser());
+
+ $reason = $this->assertParameter($parameters, 'reason');
+
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.away');
+ $rooms = array_map(function (Room $room) use ($user) {
+ return [ 'roomID' => $room->roomID
+ , 'isSilent' => !$room->canWritePublicly($user)
+ ];
+ }, (new \chat\data\user\User($user->getDecoratedObject()))->getRooms());
+
+ WCF::getDB()->beginTransaction();
+ $editor = new \wcf\data\user\UserEditor($user->getDecoratedObject());
+ $editor->update([ 'chatAway' => $reason ]);
+
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'message' => $reason
+ , 'rooms' => array_values($rooms)
+ ])
+ ]
+ , 'updateTimestamp' => true
+ ]
+ )
+ )->executeAction();
+ WCF::getDB()->commitTransaction();
+ }
+}
diff --git a/files/lib/system/command/BackCommand.class.php b/files/lib/system/command/BackCommand.class.php
new file mode 100644
index 0000000..c64cd6d
--- /dev/null
+++ b/files/lib/system/command/BackCommand.class.php
@@ -0,0 +1,80 @@
+chatAway === null) throw new PermissionDeniedException();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(WCF::getUser());
+
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.back');
+ $rooms = array_map(function (Room $room) use ($user) {
+ return [ 'roomID' => $room->roomID
+ , 'isSilent' => !$room->canWritePublicly($user)
+ ];
+ }, (new \chat\data\user\User($user->getDecoratedObject()))->getRooms());
+
+ WCF::getDB()->beginTransaction();
+ $editor = new \wcf\data\user\UserEditor($user->getDecoratedObject());
+ $editor->update([ 'chatAway' => null ]);
+
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'rooms' => array_values($rooms) ])
+ ]
+ , 'updateTimestamp' => true
+ ]
+ )
+ )->executeAction();
+ WCF::getDB()->commitTransaction();
+ }
+}
diff --git a/files/lib/system/command/BanCommand.class.php b/files/lib/system/command/BanCommand.class.php
new file mode 100644
index 0000000..fc784b9
--- /dev/null
+++ b/files/lib/system/command/BanCommand.class.php
@@ -0,0 +1,94 @@
+getPermission('mod.chat.canBan') || PermissionHandler::get($user)->getPermission($room, 'mod.canBan');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getObjectTypeName() {
+ return 'be.bastelstu.chat.suspension.ban';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function checkPermissions($parameters, Room $room, UserProfile $user) {
+ $permission = $user->getPermission('mod.chat.canBan');
+
+ if (!$this->isGlobally($parameters)) {
+ $permission = $permission || PermissionHandler::get($user)->getPermission($room, 'mod.canBan');
+ }
+
+ if (!$permission) throw new PermissionDeniedException();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function afterCreate(Suspension $suspension, $parameters, Room $room, UserProfile $user) {
+ parent::afterCreate($suspension, $parameters, $room, $user);
+
+ $user = new \chat\data\user\User($suspension->getUser());
+ $rooms = [ ];
+ if ($suspension->getRoom() === null) {
+ $rooms = $user->getRooms();
+ }
+ else {
+ if ($user->isInRoom($suspension->getRoom())) {
+ $rooms = [ $suspension->getRoom() ];
+ }
+ }
+
+ foreach ($rooms as $room) {
+ $parameters = [ 'user' => $suspension->getUser()
+ , 'roomID' => $room->roomID
+ ];
+ try {
+ (new \chat\data\room\RoomAction([ ], 'leave', $parameters))->executeAction();
+ }
+ catch (UserInputException $e) {
+ // User already left
+ }
+ }
+ }
+}
diff --git a/files/lib/system/command/BroadcastCommand.class.php b/files/lib/system/command/BroadcastCommand.class.php
new file mode 100644
index 0000000..446f494
--- /dev/null
+++ b/files/lib/system/command/BroadcastCommand.class.php
@@ -0,0 +1,85 @@
+getPermission('mod.chat.canBroadcast');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function validate($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ if (!$user->getPermission('mod.chat.canBroadcast')) throw new PermissionDeniedException();
+
+ $this->setText($this->assertParameter($parameters, 'text'));
+ $this->validateText();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.broadcast');
+ $this->setText($this->assertParameter($parameters, 'text'));
+
+ WCF::getDB()->beginTransaction();
+ $message = (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'message' => $this->processor->getHtml() ])
+ ]
+ , 'updateTimestamp' => true
+ ]
+ )
+ )->executeAction()['returnValues'];
+
+ $this->processor->setObjectID($message->messageID);
+ if (\wcf\system\message\embedded\object\MessageEmbeddedObjectManager::getInstance()->registerObjects($this->processor)) {
+ (new MessageEditor($message))->update([
+ 'hasEmbeddedObjects' => 1
+ ]);
+ }
+ WCF::getDB()->commitTransaction();
+ }
+}
diff --git a/files/lib/system/command/ColorCommand.class.php b/files/lib/system/command/ColorCommand.class.php
new file mode 100644
index 0000000..e8e705f
--- /dev/null
+++ b/files/lib/system/command/ColorCommand.class.php
@@ -0,0 +1,285 @@
+colorRegex = new \wcf\system\Regex('^#?([a-f0-9]{6})$', \wcf\system\Regex::CASE_INSENSITIVE);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getJavaScriptModuleName() {
+ return 'Bastelstu.be/Chat/Command/Color';
+ }
+
+ /**
+ * Map CSS color names to hexcodes.
+ * See: https://www.w3.org/TR/css3-color/#svg-color
+ *
+ * @var int[]
+ */
+ public static $colors = [
+ 'aliceblue' => 0xF0F8FF,
+ 'antiquewhite' => 0xFAEBD7,
+ 'aqua' => 0x00FFFF,
+ 'aquamarine' => 0x7FFFD4,
+ 'azure' => 0xF0FFFF,
+ 'beige' => 0xF5F5DC,
+ 'bisque' => 0xFFE4C4,
+ 'black' => 0x000000,
+ 'blanchedalmond' => 0xFFEBCD,
+ 'blue' => 0x0000FF,
+ 'bluescreenblue' => 0x0000AA,
+ 'blueviolet' => 0x8A2BE2,
+ 'brown' => 0xA52A2A,
+ 'burlywood' => 0xDEB887,
+ 'cadetblue' => 0x5F9EA0,
+ 'chartreuse' => 0x7FFF00,
+ 'chocolate' => 0xD2691E,
+ 'coral' => 0xFF7F50,
+ 'cornflowerblue' => 0x6495ED,
+ 'cornsilk' => 0xFFF8DC,
+ 'crimson' => 0xDC143C,
+ 'cyan' => 0x00FFFF,
+ 'darkblue' => 0x00008B,
+ 'darkcyan' => 0x008B8B,
+ 'darkgoldenrod' => 0xB8860B,
+ 'darkgray' => 0xA9A9A9,
+ 'darkgrey' => 0xA9A9A9,
+ 'darkgreen' => 0x006400,
+ 'darkkhaki' => 0xBDB76B,
+ 'darkmagenta' => 0x8B008B,
+ 'darkolivegreen' => 0x556B2F,
+ 'darkorange' => 0xFF8C00,
+ 'darkorchid' => 0x9932CC,
+ 'darkred' => 0x8B0000,
+ 'darksalmon' => 0xE9967A,
+ 'darkseagreen' => 0x8FBC8F,
+ 'darkslateblue' => 0x483D8B,
+ 'darkslategray' => 0x2F4F4F,
+ 'darkslategrey' => 0x2F4F4F,
+ 'darkturquoise' => 0x00CED1,
+ 'darkviolet' => 0x9400D3,
+ 'deeppink' => 0xFF1493,
+ 'deepskyblue' => 0x00BFFF,
+ 'dimgray' => 0x696969,
+ 'dimgrey' => 0x696969,
+ 'dodgerblue' => 0x1E90FF,
+ 'firebrick' => 0xB22222,
+ 'floralwhite' => 0xFFFAF0,
+ 'forestgreen' => 0x228B22,
+ 'fuchsia' => 0xFF00FF,
+ 'gainsboro' => 0xDCDCDC,
+ 'ghostwhite' => 0xF8F8FF,
+ 'gold' => 0xFFD700,
+ 'goldenrod' => 0xDAA520,
+ 'gray' => 0x808080,
+ 'grey' => 0x808080,
+ 'green' => 0x008000,
+ 'greenyellow' => 0xADFF2F,
+ 'honeydew' => 0xF0FFF0,
+ 'hotpink' => 0xFF69B4,
+ 'indianred' => 0xCD5C5C,
+ 'indigo' => 0x4B0082,
+ 'ivory' => 0xFFFFF0,
+ 'khaki' => 0xF0E68C,
+ 'lavender' => 0xE6E6FA,
+ 'lavenderblush' => 0xFFF0F5,
+ 'lawngreen' => 0x7CFC00,
+ 'lemonchiffon' => 0xFFFACD,
+ 'lightblue' => 0xADD8E6,
+ 'lightcoral' => 0xF08080,
+ 'lightcyan' => 0xE0FFFF,
+ 'lightgoldenrodyellow' => 0xFAFAD2,
+ 'lightgray' => 0xD3D3D3,
+ 'lightgrey' => 0xD3D3D3,
+ 'lightgreen' => 0x90EE90,
+ 'lightpink' => 0xFFB6C1,
+ 'lightsalmon' => 0xFFA07A,
+ 'lightseagreen' => 0x20B2AA,
+ 'lightskyblue' => 0x87CEFA,
+ 'lightslategray' => 0x778899,
+ 'lightslategrey' => 0x778899,
+ 'lightsteelblue' => 0xB0C4DE,
+ 'lightyellow' => 0xFFFFE0,
+ 'lime' => 0x00FF00,
+ 'limegreen' => 0x32CD32,
+ 'linen' => 0xFAF0E6,
+ 'magenta' => 0xFF00FF,
+ 'maroon' => 0x800000,
+ 'mediumaquamarine' => 0x66CDAA,
+ 'mediumblue' => 0x0000CD,
+ 'mediumorchid' => 0xBA55D3,
+ 'mediumpurple' => 0x9370D8,
+ 'mediumseagreen' => 0x3CB371,
+ 'mediumslateblue' => 0x7B68EE,
+ 'mediumspringgreen' => 0x00FA9A,
+ 'mediumturquoise' => 0x48D1CC,
+ 'mediumvioletred' => 0xC71585,
+ 'midnightblue' => 0x191970,
+ 'mintcream' => 0xF5FFFA,
+ 'mistyrose' => 0xFFE4E1,
+ 'moccasin' => 0xFFE4B5,
+ 'navajowhite' => 0xFFDEAD,
+ 'navy' => 0x000080,
+ 'oldlace' => 0xFDF5E6,
+ 'olive' => 0x808000,
+ 'olivedrab' => 0x6B8E23,
+ 'orange' => 0xFFA500,
+ 'orangered' => 0xFF4500,
+ 'orchid' => 0xDA70D6,
+ 'oxford' => 0xF02D, // looks like green
+ 'palegoldenrod' => 0xEEE8AA,
+ 'palegreen' => 0x98FB98,
+ 'paleturquoise' => 0xAFEEEE,
+ 'palevioletred' => 0xD87093,
+ 'papayawhip' => 0xFFEFD5,
+ 'peachpuff' => 0xFFDAB9,
+ 'peru' => 0xCD853F,
+ 'pink' => 0xFFC0CB,
+ 'plum' => 0xDDA0DD,
+ 'powderblue' => 0xB0E0E6,
+ 'purple' => 0x800080,
+ 'red' => 0xFF0000,
+ 'rosybrown' => 0xBC8F8F,
+ 'royalblue' => 0x4169E1,
+ 'saddlebrown' => 0x8B4513,
+ 'sadwin' => 0x2067B2,
+ 'salmon' => 0xFA8072,
+ 'sandybrown' => 0xF4A460,
+ 'seagreen' => 0x2E8B57,
+ 'seashell' => 0xFFF5EE,
+ 'sienna' => 0xA0522D,
+ 'silver' => 0xC0C0C0,
+ 'skyblue' => 0x87CEEB,
+ 'slateblue' => 0x6A5ACD,
+ 'slategray' => 0x708090,
+ 'slategrey' => 0x708090,
+ 'snow' => 0xFFFAFA,
+ 'springgreen' => 0x00FF7F,
+ 'steelblue' => 0x4682B4,
+ 'tan' => 0xD2B48C,
+ 'teal' => 0x008080,
+ 'thistle' => 0xD8BFD8,
+ 'tomato' => 0xFF6347,
+ 'turquoise' => 0x40E0D0,
+ 'violet' => 0xEE82EE,
+ 'wheat' => 0xF5DEB3,
+ 'white' => 0xFFFFFF,
+ 'whitesmoke' => 0xF5F5F5,
+ 'yellow' => 0xFFFF00,
+ 'yellowgreen' => 0x9ACD32
+ ];
+
+ /**
+ * @inheritDoc
+ */
+ public function validate($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(WCF::getUser());
+
+ if (!$user->getPermission('user.chat.canSetColor')) throw new PermissionDeniedException();
+
+ foreach ($parameters as $parameter) {
+ $value = StringUtil::trim($this->assertParameter($parameter, 'value'));
+ $valid = true;
+
+ switch ($this->assertParameter($parameter, 'type')) {
+ case 'hex':
+ if (!$this->colorRegex->match($value)) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('chat.error.invalidColor', [ 'color' => $value ]));
+ }
+ break;
+ case 'word':
+ if (!isset(self::$colors[$value])) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('chat.error.invalidColor', [ 'color' => $value ]));
+ }
+ break;
+
+ default:
+ throw new UserInputException('message');
+ }
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(WCF::getUser());
+
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.color');
+ $colors = [ ];
+
+ if (!isset($parameters[1])) $parameters[1] = $parameters[0];
+
+ foreach ($parameters as $key => $parameter) {
+ $value = StringUtil::trim($this->assertParameter($parameter, 'value'));
+
+ switch ($this->assertParameter($parameter, 'type')) {
+ case 'hex':
+ $colors[$key] = hexdec($value);
+ break;
+ case 'word':
+ if (!isset(self::$colors[$value])) throw new UserInputException('message');
+ $colors[$key] = self::$colors[$value];
+ break;
+ default:
+ throw new UserInputException('message');
+ }
+ }
+
+ WCF::getDB()->beginTransaction();
+ $editor = new \wcf\data\user\UserEditor($user->getDecoratedObject());
+ $editor->update([ 'chatColor1' => $colors[0]
+ , 'chatColor2' => $colors[1]
+ ]);
+
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'color1' => $colors[0]
+ , 'color2' => $colors[1]
+ ])
+ ]
+ , 'updateTimestamp' => true
+ ]
+ )
+ )->executeAction();
+ WCF::getDB()->commitTransaction();
+ }
+}
diff --git a/files/lib/system/command/ICommand.class.php b/files/lib/system/command/ICommand.class.php
new file mode 100644
index 0000000..8c143f0
--- /dev/null
+++ b/files/lib/system/command/ICommand.class.php
@@ -0,0 +1,76 @@
+assertUser($this->assertParameter($parameters, 'username'));
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.info');
+ $target = new \chat\data\user\User($this->getUser($this->assertParameter($parameters, 'username')));
+ $rooms = array_values(array_map(function ($assoc) {
+ $room = RoomCache::getInstance()->getRoom($assoc['roomID']);
+
+ return [ 'title' => (string) $room
+ , 'roomID' => $assoc['roomID']
+ , 'lastPush' => $assoc['lastPush']
+ , 'lastPull' => $assoc['lastPull']
+ , 'active' => $assoc['active']
+ , 'link' => $room->getLink()
+ ];
+ }, array_filter($target->getRoomAssociations(), function ($assoc) {
+ return RoomCache::getInstance()->getRoom($assoc['roomID'])->canSee();
+ })));
+
+ $payload = [ 'data' => [ 'rooms' => $rooms
+ , 'away' => $target->chatAway
+ , 'user' => $target
+ ]
+ , 'caller' => $user
+ ];
+
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'execute', $payload);
+
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize($payload['data'])
+ ]
+ , 'updateTimestamp' => true
+ ]
+ )
+ )->executeAction();
+ }
+}
diff --git a/files/lib/system/command/MeCommand.class.php b/files/lib/system/command/MeCommand.class.php
new file mode 100644
index 0000000..600784d
--- /dev/null
+++ b/files/lib/system/command/MeCommand.class.php
@@ -0,0 +1,84 @@
+canWritePublicly($user)) throw new PermissionDeniedException();
+
+ $text = $this->assertParameter($parameters, 'text');
+
+ if (mb_strlen($text) === 0) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('wcf.global.form.error.empty'));
+ }
+
+ // validate message length
+ if (mb_strlen($text) > CHAT_MAX_LENGTH) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('wcf.message.error.tooLong', [ 'maxTextLength' => CHAT_MAX_LENGTH ]));
+ }
+
+ // search for censored words
+ if (ENABLE_CENSORSHIP) {
+ $result = Censorship::getInstance()->test($text);
+ if ($result) {
+ throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('wcf.message.error.censoredWordsFound', [ 'censoredWords' => $result ]));
+ }
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.me');
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'message' => $this->assertParameter($parameters, 'text') ])
+ ]
+ , 'updateTimestamp' => true
+ , 'grantPoints' => true
+ ]
+ )
+ )->executeAction();
+ }
+}
diff --git a/files/lib/system/command/MuteCommand.class.php b/files/lib/system/command/MuteCommand.class.php
new file mode 100644
index 0000000..1f463a1
--- /dev/null
+++ b/files/lib/system/command/MuteCommand.class.php
@@ -0,0 +1,63 @@
+getPermission('mod.chat.canMute') || PermissionHandler::get($user)->getPermission($room, 'mod.canMute');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getObjectTypeName() {
+ return 'be.bastelstu.chat.suspension.mute';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function checkPermissions($parameters, Room $room, UserProfile $user) {
+ $permission = $user->getPermission('mod.chat.canMute');
+
+ if (!$this->isGlobally($parameters)) {
+ $permission = $permission || PermissionHandler::get($user)->getPermission($room, 'mod.canMute');
+ }
+
+ if (!$permission) throw new PermissionDeniedException();
+ }
+}
diff --git a/files/lib/system/command/PlainCommand.class.php b/files/lib/system/command/PlainCommand.class.php
new file mode 100644
index 0000000..2bc02a4
--- /dev/null
+++ b/files/lib/system/command/PlainCommand.class.php
@@ -0,0 +1,81 @@
+canWritePublicly($user)) throw new PermissionDeniedException();
+
+ $this->setText($this->assertParameter($parameters, 'text'));
+ $this->validateText();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.plain');
+ $this->setText($this->assertParameter($parameters, 'text'));
+ $message = (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'message' => $this->processor->getHtml() ])
+ ]
+ , 'updateTimestamp' => true
+ , 'grantPoints' => true
+ ]
+ )
+ )->executeAction()['returnValues'];
+
+ $this->processor->setObjectID($message->messageID);
+ if (\wcf\system\message\embedded\object\MessageEmbeddedObjectManager::getInstance()->registerObjects($this->processor)) {
+ (new MessageEditor($message))->update([
+ 'hasEmbeddedObjects' => 1
+ ]);
+ }
+ }
+}
diff --git a/files/lib/system/command/TNeedsUser.class.php b/files/lib/system/command/TNeedsUser.class.php
new file mode 100644
index 0000000..8a12413
--- /dev/null
+++ b/files/lib/system/command/TNeedsUser.class.php
@@ -0,0 +1,53 @@
+getUser($username);
+
+ if (!$user->userID) throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('chat.error.userNotFound', [ 'username' => $username ]));
+
+ return $user;
+ }
+}
diff --git a/files/lib/system/command/TeamCommand.class.php b/files/lib/system/command/TeamCommand.class.php
new file mode 100644
index 0000000..981572a
--- /dev/null
+++ b/files/lib/system/command/TeamCommand.class.php
@@ -0,0 +1,86 @@
+getPermission('mod.chat.canTeam');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function validate($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ if (!$user->getPermission('mod.chat.canTeam')) throw new PermissionDeniedException();
+
+ $this->setText($this->assertParameter($parameters, 'text'));
+ $this->validateText();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.team');
+ $this->setText($this->assertParameter($parameters, 'text'));
+
+ WCF::getDB()->beginTransaction();
+ $message = (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'message' => $this->processor->getHtml() ])
+ ]
+ , 'updateTimestamp' => true
+ ]
+ )
+ )->executeAction()['returnValues'];
+
+ $this->processor->setObjectID($message->messageID);
+ if (\wcf\system\message\embedded\object\MessageEmbeddedObjectManager::getInstance()->registerObjects($this->processor)) {
+ (new MessageEditor($message))->update([
+ 'hasEmbeddedObjects' => 1
+ ]);
+ }
+ WCF::getDB()->commitTransaction();
+ }
+}
diff --git a/files/lib/system/command/TemproomCommand.class.php b/files/lib/system/command/TemproomCommand.class.php
new file mode 100644
index 0000000..4c77298
--- /dev/null
+++ b/files/lib/system/command/TemproomCommand.class.php
@@ -0,0 +1,136 @@
+assertParameter($parameters, 'type')) {
+ case 'create':
+ if (!$user->getPermission('user.chat.canTemproom')) throw new PermissionDeniedException();
+ break;
+ case 'invite':
+ if (!$room->isTemporary) throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('chat.error.notInTemproom'));
+ if ($room->ownerID !== $user->userID) throw new PermissionDeniedException();
+
+ $recipient = new UserProfile($this->assertUser($this->assertParameter($parameters, 'username')));
+ if ($recipient->isIgnoredUser($user->userID)) throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('chat.error.userIgnoresYou', [ 'user' => $recipient ]));
+ break;
+ case 'delete':
+ if (!$room->isTemporary) throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('chat.error.notInTemproom'));
+ if ($room->ownerID !== $user->userID) throw new PermissionDeniedException();
+ break;
+ default:
+ throw new UserInputException('message');
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(WCF::getUser());
+
+ switch ($this->assertParameter($parameters, 'type')) {
+ case 'create':
+ $fields = [ 'title' => WCF::getLanguage()->getDynamicVariable('chat.room.temporary.blueprint', [ 'user' => $user ])
+ , 'topic' => ''
+ , 'position' => 999
+ , 'isTemporary' => true
+ , 'ownerID' => $user->userID
+ ];
+
+ WCF::getDB()->beginTransaction();
+ // create room
+ $tempRoom = (new \chat\data\room\RoomAction([], 'create', [ 'data' => $fields ]))->executeAction()['returnValues'];
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.temproomCreated');
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'room' => $tempRoom ])
+ ]
+ , 'updateTimestamp' => true
+ ]
+ )
+ )->executeAction();
+ WCF::getDB()->commitTransaction();
+ return;
+ case 'invite':
+ $recipient = $this->getUser($this->assertParameter($parameters, 'username'));
+ WCF::getDB()->beginTransaction();
+ try {
+ $sql = "INSERT INTO chat".WCF_N."_room_temporary_invite
+ (userID, roomID)
+ VALUES (?, ?)";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ $recipient->userID, $room->roomID ]);
+ }
+ catch (\wcf\system\database\DatabaseException $e) {
+ WCF::getDB()->rollBackTransaction();
+ // Duplicate key errors don't cause harm.
+ if ((string) $e->getCode() !== '23000') throw $e;
+ return;
+ }
+
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.temproomInvited');
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'recipient' => $recipient->userID
+ , 'recipientName' => $recipient->username
+ ])
+ ]
+ , 'updateTimestamp' => true
+ ]
+ )
+ )->executeAction();
+ WCF::getDB()->commitTransaction();
+
+ return;
+ case 'delete':
+ (new \chat\data\room\RoomAction([ $room ], 'delete'))->executeAction();
+ return;
+ default:
+ throw new UserInputException('message');
+ }
+ }
+}
diff --git a/files/lib/system/command/UnbanCommand.class.php b/files/lib/system/command/UnbanCommand.class.php
new file mode 100644
index 0000000..b4976af
--- /dev/null
+++ b/files/lib/system/command/UnbanCommand.class.php
@@ -0,0 +1,64 @@
+getPermission('mod.chat.canBan') || PermissionHandler::get($user)->getPermission($room, 'mod.canBan');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getObjectTypeName() {
+ return 'be.bastelstu.chat.suspension.ban';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function checkPermissions($parameters, Room $room, UserProfile $user) {
+ $permission = $user->getPermission('mod.chat.canBan');
+
+ if (!$this->isGlobally($parameters)) {
+ $permission = $permission || PermissionHandler::get($user)->getPermission($room, 'mod.canBan');
+ }
+
+ if (!$permission) throw new PermissionDeniedException();
+ }
+}
diff --git a/files/lib/system/command/UnmuteCommand.class.php b/files/lib/system/command/UnmuteCommand.class.php
new file mode 100644
index 0000000..0b7410a
--- /dev/null
+++ b/files/lib/system/command/UnmuteCommand.class.php
@@ -0,0 +1,63 @@
+getPermission('mod.chat.canMute') || PermissionHandler::get($user)->getPermission($room, 'mod.canMute');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getObjectTypeName() {
+ return 'be.bastelstu.chat.suspension.mute';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function checkPermissions($parameters, Room $room, UserProfile $user) {
+ $permission = $user->getPermission('mod.chat.canMute');
+
+ if (!$this->isGlobally($parameters)) {
+ $permission = $permission || PermissionHandler::get($user)->getPermission($room, 'mod.canMute');
+ }
+
+ if (!$permission) throw new PermissionDeniedException();
+ }
+}
diff --git a/files/lib/system/command/WhereCommand.class.php b/files/lib/system/command/WhereCommand.class.php
new file mode 100644
index 0000000..a637b03
--- /dev/null
+++ b/files/lib/system/command/WhereCommand.class.php
@@ -0,0 +1,74 @@
+getMessageObjectTypeID('be.bastelstu.chat.messageType.where');
+ $roomList = new \chat\data\room\RoomList();
+ $roomList->readObjects();
+ $rooms = array_map(function (Room $room) {
+ $users = array_map(function (\chat\data\user\User $user) {
+ return $user->jsonSerialize();
+ }, $room->getUsers());
+
+ return [ 'roomID' => $room->roomID
+ , 'users' => array_values($users)
+ ];
+ }, array_filter($roomList->getObjects(), function (Room $room) {
+ return $room->canSee();
+ }));
+
+ (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize($rooms)
+ ]
+ , 'updateTimestamp' => true
+ ]
+ )
+ )->executeAction();
+ }
+}
diff --git a/files/lib/system/command/WhisperCommand.class.php b/files/lib/system/command/WhisperCommand.class.php
new file mode 100644
index 0000000..b69c5e7
--- /dev/null
+++ b/files/lib/system/command/WhisperCommand.class.php
@@ -0,0 +1,83 @@
+assertUser($this->assertParameter($parameters, 'username')));
+ if ($recipient->isIgnoredUser($user->userID)) throw new UserInputException('message', WCF::getLanguage()->getDynamicVariable('chat.error.userIgnoresYou', [ 'user' => $recipient ]));
+
+ $this->setText($this->assertParameter($parameters, 'text'));
+ $this->validateText();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute($parameters, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $objectTypeID = $this->getMessageObjectTypeID('be.bastelstu.chat.messageType.whisper');
+ $recipient = $this->assertUser($this->assertParameter($parameters, 'username'));
+ $this->setText($this->assertParameter($parameters, 'text'));
+ $message = (new MessageAction([ ], 'create', [ 'data' => [ 'roomID' => $room->roomID
+ , 'userID' => $user->userID
+ , 'username' => $user->username
+ , 'time' => TIME_NOW
+ , 'objectTypeID' => $objectTypeID
+ , 'payload' => serialize([ 'message' => $this->processor->getHtml()
+ , 'recipient' => $recipient->userID
+ , 'recipientName' => $recipient->username
+ ])
+ ]
+ , 'updateTimestamp' => true
+ ]
+ )
+ )->executeAction()['returnValues'];
+
+ $this->processor->setObjectID($message->messageID);
+ if (\wcf\system\message\embedded\object\MessageEmbeddedObjectManager::getInstance()->registerObjects($this->processor)) {
+ (new MessageEditor($message))->update([
+ 'hasEmbeddedObjects' => 1
+ ]);
+ }
+ }
+}
diff --git a/files/lib/system/condition/room/RoomFilledCondition.class.php b/files/lib/system/condition/room/RoomFilledCondition.class.php
new file mode 100644
index 0000000..8e6b942
--- /dev/null
+++ b/files/lib/system/condition/room/RoomFilledCondition.class.php
@@ -0,0 +1,46 @@
+getConditionBuilder()->add("EXISTS (SELECT 1 FROM chat".WCF_N."_room_to_user r2u WHERE r2u.roomID = room.roomID AND active = ?)", [ 1 ]);
+ }
+}
diff --git a/files/lib/system/event/listener/HourlyCleanUpCronjobExecuteChatCleanUpListener.class.php b/files/lib/system/event/listener/HourlyCleanUpCronjobExecuteChatCleanUpListener.class.php
new file mode 100644
index 0000000..fb47586
--- /dev/null
+++ b/files/lib/system/event/listener/HourlyCleanUpCronjobExecuteChatCleanUpListener.class.php
@@ -0,0 +1,40 @@
+executeAction();
+ (new \chat\data\user\UserAction([], 'clearDeadSessions'))->executeAction();
+
+ $sql = "UPDATE chat".WCF_N."_room_to_user
+ SET active = ?
+ WHERE (roomID, userID) NOT IN (SELECT roomID, userID FROM chat".WCF_N."_session)
+ AND active = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ 0, 1 ]);
+ if ($statement->getAffectedRows()) {
+ \wcf\functions\exception\logThrowable(new \Exception('Unreachable'));
+ }
+ }
+}
diff --git a/files/lib/system/event/listener/HourlyCleanUpCronjobExecuteTemproomListener.class.php b/files/lib/system/event/listener/HourlyCleanUpCronjobExecuteTemproomListener.class.php
new file mode 100644
index 0000000..215c4a6
--- /dev/null
+++ b/files/lib/system/event/listener/HourlyCleanUpCronjobExecuteTemproomListener.class.php
@@ -0,0 +1,43 @@
+getConditionBuilder()->add('isTemporary = ?', [ 1 ]);
+ $roomList->readObjects();
+
+ $toDelete = [ ];
+ WCF::getDB()->beginTransaction();
+ foreach ($roomList as $room) {
+ if (count($room->getUsers()) === 0) {
+ $toDelete[] = $room;
+ }
+ }
+ if (!empty($toDelete)) {
+ (new \chat\data\room\RoomAction($toDelete, 'delete'))->executeAction();
+ }
+ WCF::getDB()->commitTransaction();
+ }
+}
diff --git a/files/lib/system/event/listener/InfoCommandSuspensionsListener.class.php b/files/lib/system/event/listener/InfoCommandSuspensionsListener.class.php
new file mode 100644
index 0000000..9f075e2
--- /dev/null
+++ b/files/lib/system/event/listener/InfoCommandSuspensionsListener.class.php
@@ -0,0 +1,61 @@
+getPermission('admin.chat.canManageSuspensions')) {
+ return;
+ }
+
+ $target = $parameters['data']['user'];
+
+ $parameters['data']['suspensions'] = [ ];
+
+ $suspensionList = new \chat\data\suspension\SuspensionList();
+ $suspensionList->getConditionBuilder()->add('(expires IS NULL OR expires > ?)', [ TIME_NOW ]);
+ $suspensionList->getConditionBuilder()->add('revoked IS NULL');
+ $suspensionList->getConditionBuilder()->add('userID = ?', [ $target->userID ]);
+ $suspensionList->sqlOrderBy = 'expires ASC, time ASC';
+ $suspensionList->readObjects();
+
+ $suspensions = array_filter($suspensionList->getObjects(), function (Suspension $suspension) {
+ return $suspension->isActive();
+ });
+
+ $parameters['data']['suspensions'] = array_values(array_map(function ($suspension) {
+ $room = \chat\data\room\RoomCache::getInstance()->getRoom($suspension->roomID);
+
+ $suspension = $suspension->jsonSerialize();
+ if ($room) {
+ $suspension['room'] = [ 'title' => $room->getTitle()
+ , 'link' => $room->getLink()
+ ];
+ }
+
+ return $suspension;
+ }, $suspensions));
+ }
+}
diff --git a/files/lib/system/event/listener/RoomActionGetUsersModeratorListener.class.php b/files/lib/system/event/listener/RoomActionGetUsersModeratorListener.class.php
new file mode 100644
index 0000000..5ca555d
--- /dev/null
+++ b/files/lib/system/event/listener/RoomActionGetUsersModeratorListener.class.php
@@ -0,0 +1,44 @@
+getObjects()[0]->getDecoratedObject();
+
+ $package = \wcf\data\package\PackageCache::getInstance()->getPackageByIdentifier('be.bastelstu.chat');
+ $muteCommand = CommandCache::getInstance()->getCommandByPackageAndIdentifier($package, 'mute')->getProcessor();
+ $banCommand = CommandCache::getInstance()->getCommandByPackageAndIdentifier($package, 'ban')->getProcessor();
+
+ $users = array_map(function (array $user) use ($room, $muteCommand, $banCommand) {
+ $userProfile = UserProfileRuntimeCache::getInstance()->getObject($user['userID']);
+ if (!isset($user['permissions'])) $user['permissions'] = [];
+
+ $user['permissions']['canMute'] = $muteCommand->isAvailable($room, $userProfile);
+ $user['permissions']['canBan'] = $banCommand->isAvailable($room, $userProfile);
+
+ return $user;
+ }, $users);
+ }
+}
diff --git a/files/lib/system/event/listener/RoomCanJoinBanListener.class.php b/files/lib/system/event/listener/RoomCanJoinBanListener.class.php
new file mode 100644
index 0000000..130d17f
--- /dev/null
+++ b/files/lib/system/event/listener/RoomCanJoinBanListener.class.php
@@ -0,0 +1,38 @@
+getObjectTypeIDByName('be.bastelstu.chat.suspension', 'be.bastelstu.chat.suspension.ban');
+ if (!$objectTypeID) throw new \LogicException('Unreachable');
+
+ $suspensions = Suspension::getActiveSuspensionsByTriple($objectTypeID, $parameters['user']->getDecoratedObject(), $eventObj);
+ if (!empty($suspensions)) {
+ $parameters['result'] = new PermissionDeniedException(WCF::getLanguage()->getDynamicVariable('chat.suspension.info.be.bastelstu.chat.suspension.ban'));
+ }
+ }
+}
diff --git a/files/lib/system/event/listener/RoomCanJoinUserLimitListener.class.php b/files/lib/system/event/listener/RoomCanJoinUserLimitListener.class.php
new file mode 100644
index 0000000..f417686
--- /dev/null
+++ b/files/lib/system/event/listener/RoomCanJoinUserLimitListener.class.php
@@ -0,0 +1,42 @@
+userLimit === 0) return;
+
+ $users = $eventObj->getUsers();
+ if (count($users) < $eventObj->userLimit) return;
+
+ $user = new \chat\data\user\User($parameters['user']->getDecoratedObject());
+ if ($user->isInRoom($eventObj)) return;
+
+ $canIgnoreLimit = PermissionHandler::get($parameters['user'])->getPermission($eventObj, 'mod.canIgnoreUserLimit');
+ if ($canIgnoreLimit) return;
+
+ $parameters['result'] = new PermissionDeniedException(WCF::getLanguage()->get('chat.error.roomFull'));
+ }
+}
diff --git a/files/lib/system/event/listener/RoomCanSeeTemproomListener.class.php b/files/lib/system/event/listener/RoomCanSeeTemproomListener.class.php
new file mode 100644
index 0000000..e10245c
--- /dev/null
+++ b/files/lib/system/event/listener/RoomCanSeeTemproomListener.class.php
@@ -0,0 +1,44 @@
+isTemporary) return;
+
+ $user = new \chat\data\user\User($parameters['user']->getDecoratedObject());
+ if ($eventObj->ownerID === $user->userID) return;
+
+ $sql = "SELECT COUNT(*)
+ FROM chat".WCF_N."_room_temporary_invite
+ WHERE userID = ?
+ AND roomID = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ $user->userID, $eventObj->roomID ]);
+ if ($statement->fetchSingleColumn() > 0) return;
+
+ $parameters['result'] = new PermissionDeniedException();
+ }
+}
diff --git a/files/lib/system/event/listener/RoomCanWritePubliclyMuteListener.class.php b/files/lib/system/event/listener/RoomCanWritePubliclyMuteListener.class.php
new file mode 100644
index 0000000..76cbec5
--- /dev/null
+++ b/files/lib/system/event/listener/RoomCanWritePubliclyMuteListener.class.php
@@ -0,0 +1,38 @@
+getObjectTypeIDByName('be.bastelstu.chat.suspension', 'be.bastelstu.chat.suspension.mute');
+ if (!$objectTypeID) throw new \LogicException('Unreachable');
+
+ $suspensions = Suspension::getActiveSuspensionsByTriple($objectTypeID, $parameters['user']->getDecoratedObject(), $eventObj);
+ if (!empty($suspensions)) {
+ $parameters['result'] = new PermissionDeniedException(WCF::getLanguage()->getDynamicVariable('chat.suspension.info.be.bastelstu.chat.suspension.mute'));
+ }
+ }
+}
diff --git a/files/lib/system/event/listener/RoomEditFormTemproomListener.class.php b/files/lib/system/event/listener/RoomEditFormTemproomListener.class.php
new file mode 100644
index 0000000..46837ba
--- /dev/null
+++ b/files/lib/system/event/listener/RoomEditFormTemproomListener.class.php
@@ -0,0 +1,29 @@
+room->isTemporary) {
+ throw new \wcf\system\exception\PermissionDeniedException();
+ }
+ }
+}
diff --git a/files/lib/system/event/listener/RoomListPageTemproomListener.class.php b/files/lib/system/event/listener/RoomListPageTemproomListener.class.php
new file mode 100644
index 0000000..a22d6c9
--- /dev/null
+++ b/files/lib/system/event/listener/RoomListPageTemproomListener.class.php
@@ -0,0 +1,27 @@
+objectList->getConditionBuilder()->add('isTemporary = ?', [ 0 ]);
+ }
+}
diff --git a/files/lib/system/event/listener/SuspensionListPageTemproomListener.class.php b/files/lib/system/event/listener/SuspensionListPageTemproomListener.class.php
new file mode 100644
index 0000000..d46e138
--- /dev/null
+++ b/files/lib/system/event/listener/SuspensionListPageTemproomListener.class.php
@@ -0,0 +1,31 @@
+availableRooms = array_filter($eventObj->availableRooms, function (Room $room) {
+ return !$room->isTemporary;
+ });
+ }
+}
diff --git a/files/lib/system/message/type/AwayMessageType.class.php b/files/lib/system/message/type/AwayMessageType.class.php
new file mode 100644
index 0000000..41328db
--- /dev/null
+++ b/files/lib/system/message/type/AwayMessageType.class.php
@@ -0,0 +1,80 @@
+payload['rooms']);
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => in_array($room->roomID, $roomIDs, true)
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSeeInLog()
+ */
+ public function canSeeInLog(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $roomIDs = array_map(function ($item) {
+ return $item['roomID'];
+ }, $message->payload['rooms']);
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => in_array($room->roomID, $roomIDs, true)
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSeeInLog', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see»\chat\system\message\type\IMessageType::supportsFastSelect()
+ */
+ public function supportsFastSelect() {
+ return false;
+ }
+}
diff --git a/files/lib/system/message/type/BackMessageType.class.php b/files/lib/system/message/type/BackMessageType.class.php
new file mode 100644
index 0000000..d1e98cf
--- /dev/null
+++ b/files/lib/system/message/type/BackMessageType.class.php
@@ -0,0 +1,80 @@
+payload['rooms']);
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => in_array($room->roomID, $roomIDs, true)
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSeeInLog()
+ */
+ public function canSeeInLog(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $roomIDs = array_map(function ($item) {
+ return $item['roomID'];
+ }, $message->payload['rooms']);
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => in_array($room->roomID, $roomIDs, true)
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSeeInLog', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see»\chat\system\message\type\IMessageType::supportsFastSelect()
+ */
+ public function supportsFastSelect() {
+ return false;
+ }
+}
diff --git a/files/lib/system/message/type/BroadcastMessageType.class.php b/files/lib/system/message/type/BroadcastMessageType.class.php
new file mode 100644
index 0000000..da70d29
--- /dev/null
+++ b/files/lib/system/message/type/BroadcastMessageType.class.php
@@ -0,0 +1,89 @@
+processor = new \wcf\system\html\output\HtmlOutputProcessor();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getJavaScriptModuleName() {
+ return 'Bastelstu.be/Chat/MessageType/Broadcast';
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSee()
+ */
+ public function canSee(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => true
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSeeInLog()
+ */
+ public function canSeeInLog(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => true
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSeeInLog', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function canDelete(\chat\data\message\Message $message, \wcf\data\user\UserProfile $user = null) {
+ if ($user === null) $user = new \wcf\data\user\UserProfile(\wcf\system\WCF::getUser());
+
+ return $user->getPermission('mod.chat.canDelete');
+ }
+
+ /**
+ * @see»\chat\system\message\type\IMessageType::supportsFastSelect()
+ */
+ public function supportsFastSelect() {
+ return false;
+ }
+}
diff --git a/files/lib/system/message/type/ChatUpdateMessageType.class.php b/files/lib/system/message/type/ChatUpdateMessageType.class.php
new file mode 100644
index 0000000..8c14f33
--- /dev/null
+++ b/files/lib/system/message/type/ChatUpdateMessageType.class.php
@@ -0,0 +1,54 @@
+ $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => true
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSeeInLog()
+ */
+ public function canSeeInLog(Message $message, Room $room, UserProfile $user = null) {
+ return false;
+ }
+
+ /**
+ * @see»\chat\system\message\type\IMessageType::supportsFastSelect()
+ */
+ public function supportsFastSelect() {
+ return false;
+ }
+}
diff --git a/files/lib/system/message/type/IDeletableMessageType.class.php b/files/lib/system/message/type/IDeletableMessageType.class.php
new file mode 100644
index 0000000..e99defb
--- /dev/null
+++ b/files/lib/system/message/type/IDeletableMessageType.class.php
@@ -0,0 +1,33 @@
+getPermission('mod.chat.canDelete');
+ }
+}
diff --git a/files/lib/system/message/type/PlainMessageType.class.php b/files/lib/system/message/type/PlainMessageType.class.php
new file mode 100644
index 0000000..118ebca
--- /dev/null
+++ b/files/lib/system/message/type/PlainMessageType.class.php
@@ -0,0 +1,78 @@
+processor = new \wcf\system\html\output\HtmlOutputProcessor();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getJavaScriptModuleName() {
+ return 'Bastelstu.be/Chat/MessageType/Plain';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function canDelete(\chat\data\message\Message $message, \wcf\data\user\UserProfile $user = null) {
+ if ($user === null) $user = new \wcf\data\user\UserProfile(\wcf\system\WCF::getUser());
+
+ return $user->getPermission('mod.chat.canDelete');
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::getPayload()
+ */
+ public function getPayload(\chat\data\message\Message $message, \wcf\data\user\UserProfile $user = null) {
+ if ($user === null) $user = new \wcf\data\user\UserProfile(\wcf\system\WCF::getUser());
+
+ $payload = $message->payload;
+ $payload['formattedMessage'] = null;
+ $payload['plaintextMessage'] = null;
+
+ $parameters = [ 'message' => $message
+ , 'user' => $user
+ , 'payload' => $payload
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'getPayload', $parameters);
+
+ if ($parameters['payload']['formattedMessage'] === null) {
+ $this->processor->setOutputType('text/html');
+ $this->processor->process($parameters['payload']['message'], 'be.bastelstu.chat.message', $message->messageID);
+ $parameters['payload']['formattedMessage'] = $this->processor->getHtml();
+ }
+ if ($parameters['payload']['plaintextMessage'] === null) {
+ $this->processor->setOutputType('text/plain');
+ $this->processor->process($parameters['payload']['message'], 'be.bastelstu.chat.message', $message->messageID);
+ $parameters['payload']['plaintextMessage'] = $this->processor->getHtml();
+ }
+
+ return $parameters['payload'];
+ }
+}
diff --git a/files/lib/system/message/type/SuspendMessageType.class.php b/files/lib/system/message/type/SuspendMessageType.class.php
new file mode 100644
index 0000000..b0e31bf
--- /dev/null
+++ b/files/lib/system/message/type/SuspendMessageType.class.php
@@ -0,0 +1,88 @@
+payload;
+ unset($payload['roomIDs']);
+
+ $parameters = [ 'message' => $message
+ , 'user' => $user
+ , 'payload' => $payload
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'getPayload', $parameters);
+
+ return $parameters['payload'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSee()
+ */
+ public function canSee(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => in_array($room->roomID, $message->payload['roomIDs'], true)
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSeeInLog()
+ */
+ public function canSeeInLog(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => in_array($room->roomID, $message->payload['roomIDs'], true)
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSeeInLog', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see»\chat\system\message\type\IMessageType::supportsFastSelect()
+ */
+ public function supportsFastSelect() {
+ return false;
+ }
+}
diff --git a/files/lib/system/message/type/TCanSeeCreator.class.php b/files/lib/system/message/type/TCanSeeCreator.class.php
new file mode 100644
index 0000000..f9cdb83
--- /dev/null
+++ b/files/lib/system/message/type/TCanSeeCreator.class.php
@@ -0,0 +1,67 @@
+ $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => $user->userID === $message->userID && $message->getRoom()->roomID === $room->roomID
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSeeInLog()
+ */
+ public function canSeeInLog(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => false
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSeeInLog', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see»\chat\system\message\type\IMessageType::supportsFastSelect()
+ */
+ public function supportsFastSelect() {
+ $parameters = [ 'result' => true ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'supportsFastSelect', $parameters);
+
+ return $parameters['result'];
+ }
+}
diff --git a/files/lib/system/message/type/TCanSeeInSameRoom.class.php b/files/lib/system/message/type/TCanSeeInSameRoom.class.php
new file mode 100644
index 0000000..ce263c2
--- /dev/null
+++ b/files/lib/system/message/type/TCanSeeInSameRoom.class.php
@@ -0,0 +1,66 @@
+ $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => $message->getRoom()->roomID === $room->roomID
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSeeInLog()
+ */
+ public function canSeeInLog(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => $message->getRoom()->roomID === $room->roomID
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSeeInLog', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see»\chat\system\message\type\IMessageType::supportsFastSelect()
+ */
+ public function supportsFastSelect() {
+ $parameters = [ 'result' => true ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'supportsFastSelect', $parameters);
+
+ return $parameters['result'];
+ }
+}
diff --git a/files/lib/system/message/type/TDefaultPayload.class.php b/files/lib/system/message/type/TDefaultPayload.class.php
new file mode 100644
index 0000000..d575ae9
--- /dev/null
+++ b/files/lib/system/message/type/TDefaultPayload.class.php
@@ -0,0 +1,37 @@
+payload;
+
+ $parameters = [ 'message' => $message
+ , 'user' => $user
+ , 'payload' => $payload
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'getPayload', $parameters);
+
+ return $parameters['payload'];
+ }
+}
diff --git a/files/lib/system/message/type/TeamMessageType.class.php b/files/lib/system/message/type/TeamMessageType.class.php
new file mode 100644
index 0000000..abf2483
--- /dev/null
+++ b/files/lib/system/message/type/TeamMessageType.class.php
@@ -0,0 +1,89 @@
+processor = new \wcf\system\html\output\HtmlOutputProcessor();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getJavaScriptModuleName() {
+ return 'Bastelstu.be/Chat/MessageType/Team';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function canDelete(\chat\data\message\Message $message, \wcf\data\user\UserProfile $user = null) {
+ if ($user === null) $user = new \wcf\data\user\UserProfile(\wcf\system\WCF::getUser());
+
+ return $user->getPermission('mod.chat.canDelete');
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSee()
+ */
+ public function canSee(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => $user->getPermission('mod.chat.canTeam')
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSeeInLog()
+ */
+ public function canSeeInLog(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => $user->getPermission('mod.chat.canTeam')
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSeeInLog', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see»\chat\system\message\type\IMessageType::supportsFastSelect()
+ */
+ public function supportsFastSelect() {
+ return false;
+ }
+}
diff --git a/files/lib/system/message/type/TemproomCreatedMessageType.class.php b/files/lib/system/message/type/TemproomCreatedMessageType.class.php
new file mode 100644
index 0000000..f5707a1
--- /dev/null
+++ b/files/lib/system/message/type/TemproomCreatedMessageType.class.php
@@ -0,0 +1,30 @@
+payload;
+ $room = $message->getRoom();
+ $payload['room'] = [ 'roomID' => $room->roomID
+ , 'title' => $room->title
+ , 'link' => $room->getLink()
+ ];
+
+ $parameters = [ 'message' => $message
+ , 'user' => $user
+ , 'payload' => $payload
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'getPayload', $parameters);
+
+ return $parameters['payload'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSee()
+ */
+ public function canSee(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => $user->userID === $message->userID || $user->userID === $message->payload['recipient']
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSeeInLog()
+ */
+ public function canSeeInLog(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => false
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSeeInLog', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see»\chat\system\message\type\IMessageType::supportsFastSelect()
+ */
+ public function supportsFastSelect() {
+ $parameters = [ 'result' => false ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'supportsFastSelect', $parameters);
+
+ return $parameters['result'];
+ }
+}
diff --git a/files/lib/system/message/type/TombstoneMessageType.class.php b/files/lib/system/message/type/TombstoneMessageType.class.php
new file mode 100644
index 0000000..97e5218
--- /dev/null
+++ b/files/lib/system/message/type/TombstoneMessageType.class.php
@@ -0,0 +1,63 @@
+ $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => true
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSeeInLog()
+ */
+ public function canSeeInLog(Message $message, Room $room, UserProfile $user = null) {
+ return false;
+ }
+
+ /**
+ * @see»\chat\system\message\type\IMessageType::supportsFastSelect()
+ */
+ public function supportsFastSelect() {
+ return false;
+ }
+}
diff --git a/files/lib/system/message/type/UnsuspendMessageType.class.php b/files/lib/system/message/type/UnsuspendMessageType.class.php
new file mode 100644
index 0000000..763336b
--- /dev/null
+++ b/files/lib/system/message/type/UnsuspendMessageType.class.php
@@ -0,0 +1,88 @@
+payload;
+ unset($payload['roomIDs']);
+
+ $parameters = [ 'message' => $message
+ , 'user' => $user
+ , 'payload' => $payload
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'getPayload', $parameters);
+
+ return $parameters['payload'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSee()
+ */
+ public function canSee(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => in_array($room->roomID, $message->payload['roomIDs'], true)
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSeeInLog()
+ */
+ public function canSeeInLog(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => in_array($room->roomID, $message->payload['roomIDs'], true)
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSeeInLog', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see»\chat\system\message\type\IMessageType::supportsFastSelect()
+ */
+ public function supportsFastSelect() {
+ return false;
+ }
+}
diff --git a/files/lib/system/message/type/WhereMessageType.class.php b/files/lib/system/message/type/WhereMessageType.class.php
new file mode 100644
index 0000000..878384c
--- /dev/null
+++ b/files/lib/system/message/type/WhereMessageType.class.php
@@ -0,0 +1,58 @@
+payload;
+ $payload = array_map(function ($item) {
+ $room = RoomCache::getInstance()->getRoom($item['roomID']);
+ $item['room'] = [ 'roomID' => $room->roomID
+ , 'title' => $room->title
+ , 'link' => $room->getLink()
+ ];
+ return $item;
+ }, $payload);
+
+ $parameters = [ 'message' => $message
+ , 'user' => $user
+ , 'payload' => $payload
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'getPayload', $parameters);
+
+ return $parameters['payload'];
+ }
+}
diff --git a/files/lib/system/message/type/WhisperMessageType.class.php b/files/lib/system/message/type/WhisperMessageType.class.php
new file mode 100644
index 0000000..1c69a73
--- /dev/null
+++ b/files/lib/system/message/type/WhisperMessageType.class.php
@@ -0,0 +1,113 @@
+processor = new \wcf\system\html\output\HtmlOutputProcessor();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getJavaScriptModuleName() {
+ return 'Bastelstu.be/Chat/MessageType/Whisper';
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::getPayload()
+ */
+ public function getPayload(Message $message, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $payload = $message->payload;
+ $payload['formattedMessage'] = null;
+ $payload['plaintextMessage'] = null;
+
+ $parameters = [ 'message' => $message
+ , 'user' => $user
+ , 'payload' => $payload
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'getPayload', $parameters);
+
+ if ($parameters['payload']['formattedMessage'] === null) {
+ $this->processor->process($parameters['payload']['message'], 'be.bastelstu.chat.message', $message->messageID);
+ $parameters['payload']['formattedMessage'] = $this->processor->getHtml();
+ }
+
+ if ($parameters['payload']['plaintextMessage'] === null) {
+ $this->processor->setOutputType('text/plain');
+ $this->processor->process($parameters['payload']['message'], 'be.bastelstu.chat.message', $message->messageID);
+ $parameters['payload']['plaintextMessage'] = $this->processor->getHtml();
+ }
+
+ return $parameters['payload'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSee()
+ */
+ public function canSee(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => $user->userID === $message->userID || $user->userID === $message->payload['recipient']
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSee', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see \chat\system\message\type\IMessageType::canSeeInLog()
+ */
+ public function canSeeInLog(Message $message, Room $room, UserProfile $user = null) {
+ if ($user === null) $user = new UserProfile(\wcf\system\WCF::getUser());
+
+ $parameters = [ 'message' => $message
+ , 'room' => $room
+ , 'user' => $user
+ , 'canSee' => false
+ ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'canSeeInLog', $parameters);
+
+ return $parameters['canSee'];
+ }
+
+ /**
+ * @see»\chat\system\message\type\IMessageType::supportsFastSelect()
+ */
+ public function supportsFastSelect() {
+ $parameters = [ 'result' => false ];
+ \wcf\system\event\EventHandler::getInstance()->fireAction($this, 'supportsFastSelect', $parameters);
+
+ return $parameters['result'];
+ }
+}
diff --git a/files/lib/system/page/handler/LogPageHandler.class.php b/files/lib/system/page/handler/LogPageHandler.class.php
new file mode 100644
index 0000000..d88c389
--- /dev/null
+++ b/files/lib/system/page/handler/LogPageHandler.class.php
@@ -0,0 +1,65 @@
+getRoom($objectID);
+ if ($room === null) throw new \InvalidArgumentException('Invalid room ID given');
+
+ $link = LinkHandler::getInstance()->getLink('Log', [ 'application' => 'chat'
+ , 'object' => $room
+ ]);
+ return $link;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isVisible($objectID = null) {
+ if (!WCF::getUser()->userID) return false;
+
+ if ($objectID === null) throw new \InvalidArgumentException('Invalid room ID given');
+ $room = RoomCache::getInstance()->getRoom($objectID);
+ if ($room === null) throw new \InvalidArgumentException('Invalid room ID given');
+
+ return $room->canSee() && $room->canSeeLog();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getOnlineLocation(\wcf\data\page\Page $page, \wcf\data\user\online\UserOnline $user) {
+ if ($user->pageObjectID === null) return '';
+ $room = RoomCache::getInstance()->getRoom($user->pageObjectID);
+ if ($room === null) return '';
+ if (!$room->canSeeLog()) return '';
+
+ return WCF::getLanguage()->getDynamicVariable('wcf.page.onlineLocation.'.$page->identifier, [ 'room' => $room ]);
+ }
+}
diff --git a/files/lib/system/page/handler/RoomListPageHandler.class.php b/files/lib/system/page/handler/RoomListPageHandler.class.php
new file mode 100644
index 0000000..fec8c5c
--- /dev/null
+++ b/files/lib/system/page/handler/RoomListPageHandler.class.php
@@ -0,0 +1,47 @@
+getRooms();
+ $users = array_map(function (Room $room) {
+ return array_keys($room->getUsers());
+ }, array_filter($rooms, function (Room $room) {
+ return $room->canSee();
+ }));
+
+ if (empty($users)) return 0;
+
+ return count(array_unique(call_user_func_array('array_merge', $users)));
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isVisible($objectID = null) {
+ return Room::canSeeAny();
+ }
+}
diff --git a/files/lib/system/page/handler/RoomPageHandler.class.php b/files/lib/system/page/handler/RoomPageHandler.class.php
new file mode 100644
index 0000000..4bbb55c
--- /dev/null
+++ b/files/lib/system/page/handler/RoomPageHandler.class.php
@@ -0,0 +1,68 @@
+getRoom($objectID)->getUsers());
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getLink($objectID) {
+ $room = RoomCache::getInstance()->getRoom($objectID);
+ if ($room === null) throw new \InvalidArgumentException('Invalid room ID given');
+
+ return $room->getLink();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isVisible($objectID = null) {
+ if (!WCF::getUser()->userID) return false;
+
+ if ($objectID === null) throw new \InvalidArgumentException('Invalid room ID given');
+ $room = RoomCache::getInstance()->getRoom($objectID);
+ if ($room === null) throw new \InvalidArgumentException('Invalid room ID given');
+
+ return $room->canSee();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getOnlineLocation(\wcf\data\page\Page $page, \wcf\data\user\online\UserOnline $user) {
+ if ($user->pageObjectID === null) return '';
+ $room = RoomCache::getInstance()->getRoom($user->pageObjectID);
+ if ($room === null) return '';
+ if (!$room->canSee()) return '';
+
+ return WCF::getLanguage()->getDynamicVariable('wcf.page.onlineLocation.'.$page->identifier, [ 'room' => $room ]);
+ }
+}
diff --git a/files/lib/system/page/handler/TRoomPageHandler.class.php b/files/lib/system/page/handler/TRoomPageHandler.class.php
new file mode 100644
index 0000000..c5c6ec4
--- /dev/null
+++ b/files/lib/system/page/handler/TRoomPageHandler.class.php
@@ -0,0 +1,73 @@
+getRoom($objectID);
+
+ return $room !== null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function lookup($searchString) {
+ $sql = "(SELECT ('chat.room.room' || roomID || '.title') AS languageItem
+ FROM chat".WCF_N."_room
+ WHERE title LIKE ?
+ )
+ UNION
+ (SELECT languageItem
+ FROM wcf".WCF_N."_language_item
+ WHERE languageItemValue LIKE ?
+ AND languageItem LIKE ?
+ AND languageID = ?
+ )";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([ '%'.$searchString.'%'
+ , '%'.$searchString.'%'
+ , 'chat.room.room%.title'
+ , WCF::getLanguage()->languageID
+ ]);
+
+ $results = [ ];
+ while (($row = $statement->fetchArray())) {
+ $roomID = preg_replace('/chat\.room\.room(\d+)\.title/', '\1', $row['languageItem']);
+ $room = RoomCache::getInstance()->getRoom($roomID);
+ if (!$room) continue;
+
+ $results[] = [ 'title' => $room->getTitle()
+ , 'description' => $room->getTopic()
+ , 'link' => $room->getLink()
+ , 'objectID' => $room->roomID
+ , 'image' => 'fa-comments-o'
+ ];
+ }
+
+ return $results;
+ }
+}
diff --git a/files/lib/system/permission/PermissionHandler.class.php b/files/lib/system/permission/PermissionHandler.class.php
new file mode 100644
index 0000000..dd226e1
--- /dev/null
+++ b/files/lib/system/permission/PermissionHandler.class.php
@@ -0,0 +1,133 @@
+user = $user;
+
+ $this->chatPermissions = \chat\system\cache\builder\PermissionCacheBuilder::getInstance()->getData($user->getGroupIDs());
+
+ // get user permissions
+ if ($user->userID) {
+ $ush = UserStorageHandler::getInstance();
+
+ // get ids
+ $data = $ush->getField('chatUserPermissions', $user->userID);
+
+ // cache does not exist or is outdated
+ if ($data === null) {
+ $userPermissions = [ ];
+
+ $conditionBuilder = new \wcf\system\database\util\PreparedStatementConditionBuilder();
+ $conditionBuilder->add('acl_option.objectTypeID = ?', [ ACLHandler::getInstance()->getObjectTypeID('be.bastelstu.chat.room') ]);
+ $conditionBuilder->add('option_to_user.userID = ?', [ $user->userID ]);
+ $sql = "SELECT option_to_user.objectID AS roomID,
+ option_to_user.optionValue,
+ acl_option.optionName AS permission
+ FROM wcf".WCF_N."_acl_option acl_option
+ INNER JOIN wcf".WCF_N."_acl_option_to_user option_to_user
+ ON option_to_user.optionID = acl_option.optionID
+ ".$conditionBuilder;
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute($conditionBuilder->getParameters());
+ while (($row = $statement->fetchArray())) {
+ $userPermissions[$row['roomID']][$row['permission']] = $row['optionValue'];
+ }
+
+ // update cache
+ $ush->update($user->userID, 'chatUserPermissions', serialize($userPermissions));
+ }
+ else {
+ $userPermissions = unserialize($data);
+ }
+
+ foreach ($userPermissions as $roomID => $permissions) {
+ foreach ($permissions as $name => $value) {
+ $this->chatPermissions[$roomID][$name] = $value;
+ }
+ }
+ }
+ }
+
+ public static function get(\wcf\data\user\UserProfile $user = null) {
+ if ($user === null) $user = new \wcf\data\user\UserProfile(WCF::getUser());
+ if (!isset(static::$cache[$user->userID])) {
+ static::$cache[$user->userID] = new static($user);
+ }
+
+ return static::$cache[$user->userID];
+ }
+
+ /**
+ * Fetches the given permission for the given room
+ *
+ * @param \chat\data\room\Room $room
+ * @param string $permission
+ * @return boolean
+ */
+ public function getPermission(\chat\data\room\Room $room, $permission) {
+ $groupPermission = str_replace([ 'user.', 'mod.' ], [ 'user.chat.', 'mod.chat.' ], $permission);
+
+ if (method_exists($this->user, 'getNeverPermission') && $this->user->getNeverPermission($groupPermission)) {
+ return false;
+ }
+
+ if (!isset($this->chatPermissions[$room->roomID][$permission])) {
+ return $this->user->getPermission($groupPermission);
+ }
+ return (boolean) $this->chatPermissions[$room->roomID][$permission];
+ }
+
+ /**
+ * Clears the cache.
+ */
+ public static function resetCache() {
+ UserStorageHandler::getInstance()->resetAll('chatUserPermissions');
+ \chat\system\cache\builder\PermissionCacheBuilder::getInstance()->reset();
+ }
+}
diff --git a/files/lib/system/suspension/BanSuspension.class.php b/files/lib/system/suspension/BanSuspension.class.php
new file mode 100644
index 0000000..8c54a06
--- /dev/null
+++ b/files/lib/system/suspension/BanSuspension.class.php
@@ -0,0 +1,48 @@
+getUser());
+ $room = $suspension->getRoom();
+
+ if ($user->getPermission('mod.chat.canBan')) {
+ return false;
+ }
+ if ($room !== null) {
+ if (PermissionHandler::get($user)->getPermission($room, 'mod.canBan') || PermissionHandler::get($user)->getPermission($room, 'mod.canIgnoreBan')) {
+ return false;
+ }
+ }
+ else {
+ if ($user->getPermission('mod.chat.canIgnoreBan')) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/files/lib/system/suspension/ISuspension.class.php b/files/lib/system/suspension/ISuspension.class.php
new file mode 100644
index 0000000..993701c
--- /dev/null
+++ b/files/lib/system/suspension/ISuspension.class.php
@@ -0,0 +1,31 @@
+getUser());
+ $room = $suspension->getRoom();
+
+ if ($user->getPermission('mod.chat.canMute')) {
+ return false;
+ }
+ if ($room !== null) {
+ if (PermissionHandler::get($user)->getPermission($room, 'mod.canMute') || PermissionHandler::get($user)->getPermission($room, 'mod.canIgnoreMute')) {
+ return false;
+ }
+ }
+ else {
+ if ($user->getPermission('mod.chat.canIgnoreMute')) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/files/style/be.bastelstu.chat.messageTypes.scss b/files/style/be.bastelstu.chat.messageTypes.scss
new file mode 100644
index 0000000..175034c
--- /dev/null
+++ b/files/style/be.bastelstu.chat.messageTypes.scss
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+li[data-object-type="be.bastelstu.chat.messageType.info"] {
+ .chatMessage > .box48 + .containerList {
+ margin-top: 20px;
+ }
+
+ .hideIcon {
+ float: right;
+ }
+}
+
+li[data-object-type="be.bastelstu.chat.messageType.where"] {
+ .containerList > li {
+ &:first-child {
+ border-top: none;
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .hideIcon {
+ float: right;
+ }
+ }
+}
diff --git a/files/style/be.bastelstu.chat.scss b/files/style/be.bastelstu.chat.scss
new file mode 100644
index 0000000..f3ed25f
--- /dev/null
+++ b/files/style/be.bastelstu.chat.scss
@@ -0,0 +1,635 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+$chatEmbedMaxWidth: 400px;
+
+#tpl_chat_room #chatMessageStream {
+ margin-top: 0;
+}
+
+#tpl_chat_room,
+#tpl_chat_log {
+ @include screen-md-down {
+ .main > .layoutBoundary {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ }
+
+ .sidebar {
+ display: none;
+ }
+ }
+
+ // Enable WSC 3.1 sidebar toggle on smartphones
+ @include screen-xs {
+ .sidebar {
+ &[data-show-sidebar][data-hide-sidebar] {
+ display: block;
+ flex: 0 0 auto;
+ }
+ }
+
+ .boxesSidebarLeft,
+ .boxesSidebarRight {
+ .box .boxMenu {
+ .boxMenuLink,
+ .boxMenuLinkTitle {
+ white-space: pre-wrap;
+ }
+ }
+ }
+ }
+
+ @include screen-sm-up {
+ .sidebar {
+ overflow-y: auto;
+ }
+ }
+
+ @include screen-sm-md {
+ .main > .layoutBoundary {
+ flex-direction: row !important;
+ }
+
+ #content {
+ width: auto !important;
+ }
+
+ #chatMessageStream {
+ margin-right: 10px;
+ }
+
+ .sidebar.boxesSidebarRight {
+ display: flex;
+ flex: 0.5 0 auto;
+ flex-direction: column;
+ margin-left: 10px;
+ max-width: 310px;
+
+ > .boxContainer {
+ -webkit-columns: 1;
+ -moz-columns: 1;
+ columns: 1;
+ }
+ }
+ }
+
+ @include screen-lg {
+ .boxesSidebarRight {
+ &,
+ > .boxContainer {
+ display: flex;
+ flex-direction: column;
+ }
+
+ > .boxContainer {
+ &,
+ > .box.chatUserList,
+ > [data-box-identifier="be.bastelstu.chat.roomListSidebar"] {
+ flex: 1 1 0px;
+ }
+
+ > .box {
+ &.chatUserList {
+ min-height: 15rem;
+
+ > .boxContent {
+ flex-basis: 6rem;
+ }
+ }
+
+ &[data-box-identifier="be.bastelstu.chat.roomListSidebar"] {
+ min-height: 12rem;
+
+ .badge {
+ float: right;
+ padding-left: 7px;
+ }
+
+ > .boxContent {
+ height: 6rem;
+ }
+ }
+
+ &.chatUserList,
+ &[data-box-identifier="be.bastelstu.chat.roomListSidebar"] {
+ display: flex;
+ flex-direction: column;
+
+ > .boxContent {
+ overflow-y: auto;
+ flex: 1 1 auto;
+ }
+ }
+ }
+ }
+ }
+
+ #chatQuickSettings {
+ display: none;
+ }
+ }
+
+ .main {
+ display: flex;
+
+ > .layoutBoundary {
+ flex: 1 1 auto;
+ }
+ }
+
+ .chatRoomTopic {
+ border-left: 5px solid $wcfContentBorderInner;
+ padding: 5px 0px 5px 10px;
+ margin-bottom: 10px;
+
+ .jsDismissRoomTopicButton {
+ float: right;
+ }
+ }
+
+ #content {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ width: 100%;
+ }
+
+ #chatMessageStream > .scrollContainer,
+ #chatUserList > .boxContent,
+ [data-box-identifier="be.bastelstu.chat.roomListSidebar"] > .boxContent {
+ position: relative;
+ overflow-y: scroll;
+ padding-right: 5px;
+ }
+
+ [data-box-identifier="be.bastelstu.chat.roomListSidebar"] > .boxContent {
+ overflow-x: hidden;
+
+ // Fixes issues with backgrounds being cut by the overflow-x
+ margin-left: -20px;
+
+ > div > .boxMenu {
+ margin-left: 0;
+ }
+ }
+
+ #chatUserList {
+ li.box24 {
+ > :nth-child(2) {
+ flex: 1 1 auto;
+ overflow: hidden;
+ }
+
+ > :last-child.iconColumn {
+ flex: 0 1 auto;
+ }
+ }
+ }
+
+ #chatMessageStream {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+
+ &:not(.activity) .activityInfo {
+ @extend .invisible;
+ }
+
+ > .infoMessages {
+ position: relative;
+
+ > * {
+ margin-top: 0;
+ margin-bottom: 20px;
+ }
+ }
+
+ > .scrollContainer {
+ display: flex;
+ flex: 1 1 15em;
+ flex-direction: column;
+
+ > ul {
+ > li {
+ &.dateMarker {
+ text-align: center;
+ @include wcfFontBold;
+ }
+
+ &:target {
+ background-color: rgba(255, 255, 102, 0.4); // .codeBoxJumpAnchor:target::after
+ }
+
+ &.readMarker {
+ border-bottom: 2px dashed rgba(204, 0, 0, 1); // .badge.red
+ margin-bottom: 0px !important;
+
+ & + .first {
+ border-top: none;
+ }
+ }
+
+ &:first-child.first {
+ border-top: none;
+ }
+
+ .chatMessageContainer {
+ display: flex;
+ margin-top: 3px;
+ margin-bottom: 3px;
+ position: relative;
+
+ // Allows to easily add a marker for special messages like mentions
+ border-left: 3px solid transparent;
+
+ .chatMessageContent {
+ flex: 1 1 auto;
+
+ // Limit embedded images and videos to a reasonable size
+ img:not(.smiley):not(.userAvatarImage) {
+ width: 100%;
+ max-width: $chatEmbedMaxWidth;
+ }
+
+ .videoContainer {
+ @media screen and (min-width: $chatEmbedMaxWidth) {
+ padding-bottom: ($chatEmbedMaxWidth / 16 * 9);
+ }
+
+ > iframe {
+ max-width: $chatEmbedMaxWidth;
+ max-height: ($chatEmbedMaxWidth / 16 * 9);
+ }
+ }
+ }
+
+ .chatMessageIcon {
+ float: left;
+ margin-right: 5px;
+ }
+
+ &,
+ &.inline {
+ .chatMessageSide > .chatUserAvatar,
+ .chatMessageContent > .chatMessageHeader {
+ display: none;
+ }
+ }
+
+ .chatMessageSide {
+ min-width: 58px;
+ display: flex;
+ flex: 0 0 auto;
+ flex-direction: column;
+ align-items: center;
+
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+
+ > time {
+ @extend .small;
+ display: none;
+ }
+ }
+
+ .chatMessageHeader {
+ .username {
+ font-weight: bold;
+ }
+ }
+ }
+
+ &.first,
+ &:hover {
+ .chatMessageSide > time {
+ display: inline-block;
+ }
+ }
+
+ &.first {
+ border-top: 1px solid $wcfContentBorderInner;
+
+ .chatMessageContainer {
+ .chatMessageSide {
+ > .chatUserAvatar {
+ margin-top: 3px;
+ display: block;
+ }
+
+ > time {
+ display: none;
+ }
+ }
+
+ .chatMessageContent {
+ > .chatMessageHeader {
+ display: block;
+ }
+ }
+
+ &.inline {
+ .chatMessageSide {
+ > .chatUserAvatar {
+ display: none;
+ }
+
+ > time {
+ display: inline-block;
+ }
+ }
+
+ .chatMessageContent {
+ > .chatMessageHeader {
+ display: none;
+ }
+ }
+ }
+ }
+ }
+
+ .buttonList {
+ display: none;
+
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+ }
+ }
+ }
+ }
+ }
+
+ #chatInputContainer {
+ margin-top: 10px;
+ clear: both;
+
+ textarea {
+ resize: none;
+ }
+
+ .charCounter {
+ float: right;
+ color: $wcfContentDimmedText;
+ }
+
+ > div {
+ display: flex;
+ align-items: center;
+
+ > .flexibleTextarea {
+ flex: 1 0 auto;
+ max-width: 100%;
+ }
+
+ > #chatQuickSettings {
+ flex: 0 0 auto;
+ }
+ }
+
+ .innerError {
+ float: left;
+ }
+ }
+
+ #chatQuickSettingsNavigation {
+ @extend .buttonGroupNavigation;
+
+ position: relative;
+
+ > .buttonGroup {
+ @include screen-lg {
+ justify-content: flex-end;
+
+ > li > .button {
+ @extend .small;
+ }
+ }
+
+ @include screen-md-down {
+ @include dropdownMenu;
+
+ &.open {
+ display: block;
+ visibility: visible;
+ position: absolute;
+ right: 24px !important;
+ bottom: 0;
+
+ > li {
+ margin-right: 0;
+ }
+
+ // these rules are required to work around the .button default styling
+ .button {
+ @include wcfFontDefault;
+
+ &.active,
+ &.active:hover {
+ color: $wcfButtonTextActive !important;
+ }
+
+ &:not(.active) {
+ background-color: transparent;
+ color: $wcfDropdownLink;
+ }
+
+ border-radius: 0;
+ }
+ }
+ }
+ }
+ }
+
+ .smiliesToggleMobileButton {
+ margin-right: 5px;
+ }
+
+ #chatQuickSettings {
+ margin-left: 5px;
+ }
+
+ #smileyPickerContainer {
+ #smilies-text {
+ @if variable_exists(wcfContentContainerBackground) {
+ background-color: $wcfContentContainerBackground;
+ }
+ @else {
+ // Compatibility with API_VERSION 3.0
+ background-color: rgba(255, 255, 255, 1);
+ }
+
+ border: 1px solid $wcfContentBorderInner;
+ padding: 20px;
+ margin-top: 20px;
+ margin-bottom: 20px;
+
+ > .smileyList {
+ overflow: auto;
+ }
+ }
+
+ #smileyPickerCloseButton {
+ display: none;
+ }
+
+ @include screen-md-down {
+ &[data-show="true"] {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 9001;
+ display: flex;
+ flex-direction: column;
+ pointer-events: all;
+
+ #smileyPickerCloseButton {
+ background-color: $wcfSidebarBackground;
+ color: $wcfSidebarLink;
+ display: block;
+ padding: 10px 20px;
+ text-align: center;
+ flex: 0 0 auto;
+ cursor: pointer;
+ }
+
+ #smilies-text {
+ border-top: none;
+ border-right: none;
+ border-left: none;
+
+ margin: 0;
+ height: 0;
+ flex: 1 1 auto;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+
+ > nav > ul {
+ margin-bottom: -5px;
+
+ > li {
+ margin-right: 10px;
+ margin-bottom: 5px;
+ border-right: 1px solid $wcfContentBorderInner;
+ padding-right: 9px;
+
+ > a {
+ font-size: 15px;
+ }
+ }
+ }
+
+ .messageTabMenuContent {
+ overflow: auto;
+ flex: 1 1 auto;
+ }
+ }
+ }
+ }
+ }
+}
+
+html.fullscreen {
+ #tpl_chat_room,
+ #tpl_chat_log {
+ .pageHeaderContainer,
+ .pageNavigation,
+ .pageFooter {
+ display: none;
+ }
+
+ .main {
+ @include screen-sm-up {
+ height: 0; // Workaround to get Firefox and Chrome to behave the same regarding page overflow
+ }
+
+ padding: 14px 0;
+
+ .layoutBoundary {
+ max-width: none;
+ width: auto;
+ }
+ }
+
+ #chatMessageStream {
+ > .scrollContainer {
+ // flex: 1 1 0; // Disable min height in fullscreen mode
+ }
+ }
+
+ // The to top button is clickable even when invisible and may lay over the chat input
+ // This button is unnecessary in the chat, therefore we hide it completely.
+ // If necessary, we should change the selector to .toTop[aria-hidden="true"].
+ .pageAction > .toTop {
+ display: none;
+ pointer-events: none;
+ }
+ }
+}
+
+html:not(.mobile) {
+ #tpl_chat_room,
+ #tpl_chat_log {
+ #chatMessageStream {
+ .chatMessageContainer {
+ .buttonList {
+ position: absolute;
+ bottom: -1px;
+ right: 0px;
+
+ .button {
+ padding: 4px 6px;
+ }
+ }
+
+ &:hover {
+ > .buttonList {
+ display: flex;
+ }
+ }
+ }
+ }
+ }
+}
+
+// based on https://github.com/alexdunphy/flexText
+.flexibleTextarea {
+ position: relative;
+
+ > .flexibleTextareaContent,
+ > .flexibleTextareaMirror {
+ max-height: 200px;
+ overflow: auto;
+ }
+
+ > .flexibleTextareaContent {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ resize: none;
+ }
+
+ > .flexibleTextareaMirror {
+ display: block;
+ visibility: hidden;
+
+ @extend textarea;
+ }
+}
diff --git a/files_wcf/js/Bastelstu.be/Chat.js b/files_wcf/js/Bastelstu.be/Chat.js
new file mode 100644
index 0000000..d06c92a
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat.js
@@ -0,0 +1,454 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Chat/console'
+ , 'Bastelstu.be/bottle'
+ , 'Bastelstu.be/_Push'
+ , 'WoltLabSuite/Core/Core'
+ , 'WoltLabSuite/Core/Language'
+ , 'WoltLabSuite/Core/Timer/Repeating'
+ , 'WoltLabSuite/Core/User'
+ , './Chat/Autocompleter'
+ , './Chat/CommandHandler'
+ , './Chat/DataStructure/Throttle'
+ , './Chat/Message'
+ , './Chat/Messenger'
+ , './Chat/ParseError'
+ , './Chat/ProfileStore'
+ , './Chat/Room'
+ , './Chat/Template'
+ , './Chat/Ui/AutoAway'
+ , './Chat/Ui/Chat'
+ , './Chat/Ui/ConnectionWarning'
+ , './Chat/Ui/ErrorDialog'
+ , './Chat/Ui/Input'
+ , './Chat/Ui/Input/Autocompleter'
+ , './Chat/Ui/MessageStream'
+ , './Chat/Ui/MessageActions/Delete'
+ , './Chat/Ui/Mobile'
+ , './Chat/Ui/Notification'
+ , './Chat/Ui/ReadMarker'
+ , './Chat/Ui/Settings'
+ , './Chat/Ui/Topic'
+ , './Chat/Ui/UserActionDropdownHandler'
+ , './Chat/Ui/UserList'
+ ], function (console, Bottle, Push, Core, Language, RepeatingTimer, CoreUser, Autocompleter,
+ CommandHandler, Throttle, Message, Messenger, ParseError, ProfileStore, Room, Template, UiAutoAway, Ui,
+ UiConnectionWarning, ErrorDialog, UiInput, UiInputAutocompleter, UiMessageStream, UiMessageActionDelete, UiMobile, UiNotification,
+ UiReadMarker, UiSettings, UiTopic, UiUserActionDropdownHandler, UiUserList) {
+ "use strict";
+
+ class Chat {
+ constructor(roomID, config) {
+ console.debug('Chat.constructor', 'Constructing …')
+
+ this.config = config
+
+ this.sessionID = Core.getUuid()
+
+ // Setup Bottle containers
+ this.bottle = new Bottle()
+ this.bottle.value('bottle', this.bottle)
+ this.bottle.value('config', config)
+ this.bottle.constant('sessionID', this.sessionID)
+ this.bottle.constant('roomID', roomID)
+
+ // Register chat components
+ this.service('Autocompleter', Autocompleter)
+ this.service('CommandHandler', CommandHandler)
+ this.service('Messenger', Messenger)
+ this.service('ProfileStore', ProfileStore)
+ this.service('Room', Room)
+
+ // Register UI components
+ this.service('Ui', Ui)
+ this.service('UiAutoAway', UiAutoAway)
+ this.service('UiConnectionWarning', UiConnectionWarning)
+ this.service('UiInput', UiInput)
+ this.service('UiInputAutocompleter', UiInputAutocompleter)
+ this.service('UiMessageActionDelete', UiMessageActionDelete)
+ this.service('UiMessageStream', UiMessageStream)
+ this.service('UiMobile', UiMobile)
+ this.service('UiNotification', UiNotification)
+ this.service('UiReadMarker', UiReadMarker)
+ this.service('UiSettings', UiSettings)
+ this.service('UiTopic', UiTopic)
+ this.service('UiUserActionDropdownHandler', UiUserActionDropdownHandler)
+ this.service('UiUserList', UiUserList)
+
+ // Register Models
+ this.bottle.instanceFactory('Message', (container, m) => {
+ return new Message(container.MessageType, m)
+ })
+
+ // Register Templates
+ const selector = [ '[type="x-text/template"]'
+ , '[data-application="be.bastelstu.chat"]'
+ , '[data-template-name]'
+ ].join('')
+
+ const templates = elBySelAll(selector)
+
+ Array.prototype.forEach.call(templates, (function (template) {
+ this.bottle.factory(`Template.${template.dataset.templateName}`, function (container) {
+ const includeNames = (template.dataset.templateIncludes || '').split(/ /).filter(item => item !== "")
+ const includes = { }
+ includeNames.forEach(item => includes[item] = container[item])
+
+ return new Template(template.textContent, includes)
+ })
+ }).bind(this))
+
+ // Register MessageTypes
+ Object.entries(this.config.messageTypes)
+ .forEach(([ objectType, messageType ]) => {
+ const MessageType = require(messageType.module)
+
+ this.bottle.factory(`MessageType.${objectType.replace(/\./g, '-')}`, _ => {
+ const deps = this.bottle.digest(MessageType.DEPENDENCIES || [])
+
+ return new MessageType(...deps, objectType)
+ })
+ })
+
+ // Register Commands
+ Object.values(this.config.commands).forEach(command => {
+ const Command = require(command.module)
+
+ this.bottle.factory(`Command.${command.package.replace(/\./g, '-')}:${command.identifier}`, _ => {
+ const deps = this.bottle.digest(Command.DEPENDENCIES || [])
+
+ return new Command(...deps, command)
+ })
+ })
+ this.bottle.constant('Trigger', new Map(Object.entries(this.config.triggers).map(([ trigger, commandID ]) => {
+ const command = this.config.commands[commandID]
+ const key = [ command.package, command.identifier ]
+ return [ trigger, key ]
+ })))
+
+ // Register Settings
+ Array.from(elBySelAll('#chatQuickSettingsNavigation .button[data-module]')).forEach(item => {
+ const Button = require(item.dataset.module)
+
+ this.bottle.instanceFactory(`UiSettingsButton.${item.dataset.module.replace(/\./g, '-')}`, (_, element) => {
+ const deps = this.bottle.digest(Button.DEPENDENCIES || [])
+ return new Button(element, ...deps)
+ })
+ })
+
+ this.knows = { from: undefined
+ , to: undefined
+ }
+
+ this.processMessagesThrottled = Throttle(this.processMessages.bind(this))
+ this.queuedMessages = [ ]
+ this.messageSinks = new Set()
+
+ this.pullTimer = undefined
+ this.pullUserListTimer = undefined
+ this.pushConnected = false
+
+ this.firstFailure = null
+ }
+
+ service(name, _constructor, args = [ ]) {
+ this.bottle.factory(name, _ => {
+ const deps = this.bottle.digest(_constructor.DEPENDENCIES || [ ])
+
+ return new _constructor(...deps, ...args)
+ })
+ }
+
+ async bootstrap() {
+ console.debug('Chat.bootstrap', 'Initializing …')
+
+ this.ui = this.bottle.container.Ui
+ this.ui.bootstrap()
+
+ this.bottle.container.UiInput.on('submit', this.onSubmit.bind(this))
+ this.bottle.container.UiInput.on('autocomplete', this.onAutocomplete.bind(this))
+
+ await this.bottle.container.Room.join()
+
+ // Bind unload event to leave the Chat
+ window.addEventListener('unload', this.bottle.container.Room.leave.bind(this.bottle.container.Room, true))
+ document.addEventListener('visibilitychange', _ => {
+ this.processMessagesThrottled.setDelay(document.hidden ? 10000 : 125)
+ })
+
+ this.pullTimer = new RepeatingTimer(Throttle(this.pullMessages.bind(this)), this.config.reloadTime * 1e3)
+
+ Push.onConnect(_ => {
+ console.debug('Chat.bootstrap', 'Push connected')
+ this.pushConnected = true
+ this.pullTimer.setDelta(30e3)
+ })
+ .catch(error => { console.debug(error) })
+
+ Push.onDisconnect(_ => {
+ console.debug('Chat.bootstrap', 'Push disconnected')
+ this.pushConnected = false
+ this.pullTimer.setDelta(this.config.reloadTime * 1e3)
+ })
+ .catch(error => { console.debug(error) })
+
+ Push.onMessage('be.bastelstu.chat.message', this.pullMessages.bind(this))
+ .catch(error => { console.debug(error) })
+
+ // Fetch user list every 60 seconds
+ // This acts as a safety net: It should be kept current by messages whenever possible.
+ this.pullUserListTimer = new RepeatingTimer(this.updateUsers.bind(this), 60e3)
+
+ this.registerMessageSink(this.bottle.container.UiMessageStream)
+ this.registerMessageSink(this.bottle.container.UiNotification)
+ this.registerMessageSink(this.bottle.container.UiAutoAway)
+
+ await Promise.all([ this.pullMessages()
+ , this.updateUsers()
+ , this.bottle.container.ProfileStore.ensureUsersByIDs([ CoreUser.userId ])
+ ])
+
+ return this
+ }
+
+ registerMessageSink(sink) {
+ if (typeof sink.ingest !== 'function') {
+ throw new Error('The given sink does not provide a .ingest function.')
+ }
+
+ this.messageSinks.add(sink)
+ }
+
+ unregisterMessageSink(sink) {
+ this.messageSinks.delete(sink)
+ }
+
+ hcf(err = undefined) {
+ console.debug('Chat.hcf', 'Gotcha! FIRE was caught! FIRE’s data was newly added to the POKéDEX.', err)
+
+ this.pullTimer.stop()
+ this.pullUserListTimer.stop()
+
+ new ErrorDialog(Language.get('chat.error.hcf', { err }))
+ }
+
+ async onSubmit(event) {
+ const input = event.target
+ const value = input.getText()
+
+ console.debug('Chat.onSubmit', `Pushing message: ${value}`)
+
+ // Clear message input
+ input.insertText('', { append: false })
+
+ this.markAsBack()
+
+ let [ trigger, parameterString ] = this.bottle.container.CommandHandler.splitCommand(value)
+ let command = null
+ if (trigger === null) {
+ command = this.bottle.container.CommandHandler.getCommandByIdentifier('be.bastelstu.chat', 'plain')
+ }
+ else {
+ command = this.bottle.container.CommandHandler.getCommandByTrigger(trigger)
+ }
+
+ if (command === null) {
+ this.ui.input.inputError(Language.get('chat.error.triggerNotFound', { trigger }))
+ return
+ }
+
+ try {
+ let parameters
+ try {
+ parameters = this.bottle.container.CommandHandler.applyCommand(command, parameterString)
+ }
+ catch (e) {
+ if (e instanceof ParseError) {
+ e = new Error(Language.get('chat.error.invalidParameters', { data: e.data }))
+ }
+ throw e
+ }
+
+ const payload = { commandID: command.id
+ , parameters
+ }
+
+ try {
+ await this.bottle.container.Messenger.push(payload)
+ this.ui.input.hideInputError()
+ }
+ catch (error) {
+ let seriousError = true
+ if (error.returnValues && error.returnValues.fieldName === 'message' && (error.returnValues.realErrorMessage || error.returnValues.errorType)) {
+ this.ui.input.inputError(error.returnValues.realErrorMessage || error.returnValues.errorType)
+ seriousError = false
+ }
+ else {
+ this.ui.input.inputError(error.message)
+ }
+
+ if (seriousError) {
+ this.handleError(error)
+ }
+ }
+
+ // We assume that a running push server will push us our own message
+ if (!this.pushConnected) {
+ this.pullMessages()
+ }
+
+ console.debug('Chat.onSubmit', `Done`)
+ }
+ catch (e) {
+ this.ui.input.inputError(e.message)
+ }
+ }
+
+ async markAsBack() {
+ try {
+ if (this.bottle.container.ProfileStore.getSelf().away == null) return
+ console.debug('Chat.markAsBack', `Marking as back`)
+
+ const command = this.bottle.container.CommandHandler.getCommandByIdentifier('be.bastelstu.chat', 'back')
+ return this.bottle.container.Messenger.push({ commandID: command.id, parameters: { } })
+ }
+ catch (err) {
+ console.error('Chat.markAsBack', err)
+ }
+ }
+
+ onAutocomplete(event) {
+ const input = event.target
+ const value = input.getText(true)
+
+ console.debug('Chat.onAutocomplete', `Autocompleting message: ${value}`)
+
+ const result = this.bottle.container.Autocompleter.autocomplete(value)
+ const returnValues = []
+ for (const item of result) {
+ returnValues.push({ label: item, objectID: item })
+ if (returnValues.length == 5) break
+ }
+
+ const payload = { returnValues }
+ this.ui.autocompleter._ajaxSuccess(payload)
+ }
+
+ async pullMessages() {
+ console.debug('Chat.pullMessages', `Pulling new messages, starting at ${this.knows.to ? this.knows.to + 1 : ''}`)
+
+ let payload
+ try {
+ if (this.knows.to === undefined) {
+ payload = await this.bottle.container.Messenger.pull()
+ }
+ else {
+ payload = await this.bottle.container.Messenger.pull(this.knows.to + 1)
+ }
+ }
+ catch (e) {
+ this.handleError(e)
+ return
+ }
+
+ console.debug('Chat.pullMessages', `Handling result: `, payload)
+ const start = (performance ? performance : Date).now()
+ this.ui.connectionWarning.hide()
+ this.firstFailure = null
+
+ // Null range: No messages satisfy the constraints
+ if (payload.from > payload.to) {
+ const end = (performance ? performance : Date).now()
+ console.debug('Chat.pullMessages', `took ${(end - start) / 1000}s`)
+ return
+ }
+
+ let messages = payload.messages
+
+ if (this.knows.from !== undefined && this.knows.to !== undefined) {
+ messages = messages.filter((message) => {
+ return !(this.knows.from <= message.messageID && message.messageID <= this.knows.to)
+ })
+ }
+
+ if (this.knows.from === undefined || payload.from < this.knows.from) this.knows.from = payload.from
+ if (this.knows.to === undefined || payload.to > this.knows.to) this.knows.to = payload.to
+
+ this.queuedMessages.push(messages)
+ const end = (performance ? performance : Date).now()
+ console.debug('Chat.pullMessages', `took ${(end - start) / 1000}s`)
+
+ this.processMessagesThrottled()
+ }
+
+ handleError(error) {
+ if (this.firstFailure === null) {
+ console.error('Chat.handleError', `Request failed, 30 seconds until shutdown`)
+ this.firstFailure = Date.now()
+ this.ui.connectionWarning.show()
+ }
+
+ console.debugException(error)
+
+ if ((Date.now() - this.firstFailure) >= 30e3) {
+ console.error('Chat.handleError', ' Failures for 30 seconds, aborting')
+
+ this.hcf(error)
+ }
+ }
+
+ async processMessages() {
+ console.debug('Chat.processMessages', 'Processing messages')
+ const start = (performance ? performance : Date).now()
+ const messages = [ ].concat(...this.queuedMessages)
+ this.queuedMessages = []
+
+ if (messages.length === 0) return
+
+ await Promise.all(messages.map(async (message) => {
+ this.bottle.container.ProfileStore.pushLastActivity(message.userID)
+
+ return message.getMessageType().preProcess(message)
+ }))
+
+ const updateUserList = messages.some((message) => {
+ return message.getMessageType().shouldUpdateUserList(message)
+ })
+
+ if (updateUserList) {
+ this.updateUsers()
+ }
+
+ await this.bottle.container.ProfileStore.ensureUsersByIDs([ ].concat(...messages.map(message => message.getMessageType().getReferencedUsers(message))))
+
+ messages.forEach((message) => {
+ message.getMessageType().preRender(message)
+ })
+
+ this.messageSinks.forEach(sink => sink.ingest(messages))
+ const end = (performance ? performance : Date).now()
+ console.debug('Chat.processMessages', `took ${(end - start) / 1000}s`)
+ }
+
+ async updateUsers() {
+ console.debug('Chat.updateUsers')
+
+ const users = await this.bottle.container.Room.getUsers()
+ await this.bottle.container.ProfileStore.ensureUsersByIDs(users.map(user => user.userID))
+ this.ui.userList.render(users)
+ }
+ }
+
+ return Chat
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Autocompleter.js b/files_wcf/js/Bastelstu.be/Chat/Autocompleter.js
new file mode 100644
index 0000000..25292fc
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Autocompleter.js
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './CommandHandler'
+ , './Parser'
+ ], function (CommandHandler, Parser) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'CommandHandler' ]
+ class Autocompleter {
+ constructor(commandHandler) {
+ if (!(commandHandler instanceof CommandHandler)) throw new TypeError('You must pass a CommandHandler to the Autocompleter')
+
+ this.commandHandler = commandHandler
+ }
+
+ * autocomplete(text) {
+ if (text === '/') {
+ yield * this.autocompleteCommandTrigger(text, '')
+ return
+ }
+
+ const [ trigger, parameterString ] = this.commandHandler.splitCommand(text)
+
+ let command
+ if (trigger === null) {
+ command = this.commandHandler.getCommandByIdentifier('be.bastelstu.chat', 'plain')
+ }
+ else {
+ const triggerDone = Parser.Slash.thenRight(Parser.AlnumTrigger.or(Parser.SymbolicTrigger).thenLeft(Parser.Whitespace)).parse(Parser.Streams.ofString(text))
+ if (!triggerDone.isAccepted()) {
+ yield * this.autocompleteCommandTrigger(text, trigger)
+ return
+ }
+
+ command = this.commandHandler.getCommandByTrigger(trigger)
+ }
+
+ if (command === null) {
+ return
+ }
+
+ const values = command.autocomplete(parameterString)
+
+ if (trigger !== null) {
+ for (const item of values) {
+ yield `/${trigger} ${item}`
+ }
+ }
+ else {
+ yield * values
+ }
+ }
+
+ * autocompleteCommandTrigger(text, prefix) {
+ const triggers = Array.from(this.commandHandler.getTriggers())
+
+ triggers.sort()
+
+ for (const trigger of triggers) {
+ if (trigger === '') continue
+ if (!trigger.startsWith(prefix)) continue
+ if (!this.commandHandler.getCommandByTrigger(trigger).isAvailable) continue
+
+ yield `/${trigger} `
+ }
+ }
+ }
+ Autocompleter.DEPENDENCIES = DEPENDENCIES
+
+ return Autocompleter
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/BoxRoomList.js b/files_wcf/js/Bastelstu.be/Chat/BoxRoomList.js
new file mode 100644
index 0000000..30a23e7
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/BoxRoomList.js
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './console'
+ , 'Bastelstu.be/_Push'
+ , 'WoltLabSuite/Core/Dom/Util'
+ , 'WoltLabSuite/Core/Timer/Repeating'
+ , 'Bastelstu.be/PromiseWrap/Ajax'
+ ], function (console, Push, DomUtil, RepeatingTimer, Ajax) {
+ "use strict";
+
+ let timer = undefined
+ const mapping = new Map()
+
+ class BoxRoomList {
+ constructor(container) {
+ this.container = container
+
+ mapping.set(container, this)
+
+ if (timer == null) {
+ timer = new RepeatingTimer(BoxRoomList.updateBoxes.bind(BoxRoomList), 60e3)
+ }
+
+ Push.onConnect(timer.setDelta.bind(timer, 300e3)).catch(error => { console.debug(error) })
+ Push.onDisconnect(timer.setDelta.bind(timer, 60e3)).catch(error => { console.debug(error) })
+ Push.onMessage('be.bastelstu.chat.join', BoxRoomList.updateBoxes.bind(BoxRoomList)).catch(error => { console.debug(error) })
+ Push.onMessage('be.bastelstu.chat.leave', BoxRoomList.updateBoxes.bind(BoxRoomList)).catch(error => { console.debug(error) })
+ }
+
+ static updateBoxes() {
+ mapping.forEach(object => {
+ object.update()
+ })
+ }
+
+ async update() {
+ const payload = { className: 'chat\\data\\room\\RoomAction'
+ , actionName: 'getBoxRoomList'
+ , parameters: { }
+ }
+
+ payload.parameters.activeRoomID = this.container.dataset.activeRoomId
+ payload.parameters.boxID = this.container.dataset.boxId
+ payload.parameters.isSidebar = this.container.dataset.isSidebar
+ payload.parameters.skipEmptyRooms = this.container.dataset.skipEmptyRooms
+
+ this.replace(await Ajax.api(this, payload))
+ }
+
+ replace(data) {
+ if (data.returnValues.template == null) throw new Error('template could not be found in returnValues')
+
+ const fragment = DomUtil.createFragmentFromHtml(data.returnValues.template)
+ const oldRoomList = this.container.querySelector('.chatBoxRoomList')
+ const newRoomList = fragment.querySelector('.chatBoxRoomList')
+
+ if (oldRoomList == null) {
+ throw new Error('.chatBoxRoomList could not be found in container')
+ }
+ if (newRoomList == null) {
+ throw new Error('.chatBoxRoomList could not be found in returned template')
+ }
+
+ if (oldRoomList.dataset.hash !== newRoomList.dataset.hash) {
+ this.container.replaceChild(newRoomList, oldRoomList)
+ }
+ }
+
+ _ajaxSetup() {
+ return { silent: true
+ , ignoreError: true
+ }
+ }
+ }
+
+ return BoxRoomList
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command.js b/files_wcf/js/Bastelstu.be/Chat/Command.js
new file mode 100644
index 0000000..a70732c
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command.js
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Parser' ], function (Parser) {
+ "use strict";
+
+ const data = Symbol('data')
+
+ /**
+ * Represents a chat command.
+ */
+ class Command {
+ constructor(_data) {
+ this[data] = _data
+ }
+
+ getParameterParser() {
+ return Parser.Rest
+ }
+
+ * autocomplete(parameterString) {
+
+ }
+
+ get id() {
+ return this[data].commandID
+ }
+
+ get package() {
+ return this[data].package
+ }
+
+ get identifier() {
+ return this[data].identifier
+ }
+
+ get module() {
+ return this[data].module
+ }
+
+ get isAvailable() {
+ return this[data].isAvailable
+ }
+ }
+
+ return Command
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Away.js b/files_wcf/js/Bastelstu.be/Chat/Command/Away.js
new file mode 100644
index 0000000..503181a
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Away.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../Command'
+ , '../Parser'
+ ], function (Command, Parser) {
+ "use strict";
+
+ class Away extends Command {
+ getParameterParser() {
+ return Parser.Rest.map(reason => ({ reason }))
+ }
+ }
+
+ return Away
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Back.js b/files_wcf/js/Bastelstu.be/Chat/Command/Back.js
new file mode 100644
index 0000000..dd05a0b
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Back.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../Command'
+ , '../Parser'
+ ], function (Command, Parser) {
+ "use strict";
+
+ class Back extends Command {
+ getParameterParser() {
+ return Parser.F.eos
+ }
+ }
+
+ return Back
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Ban.js b/files_wcf/js/Bastelstu.be/Chat/Command/Ban.js
new file mode 100644
index 0000000..eb89882
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Ban.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 3
+ * or later of the General Public License.
+ */
+
+define([ './_Suspension' ], function (Suspension) {
+ "use strict";
+
+ class Ban extends Suspension {
+
+ }
+
+ return Ban
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Broadcast.js b/files_wcf/js/Bastelstu.be/Chat/Command/Broadcast.js
new file mode 100644
index 0000000..0df7012
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Broadcast.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Plain' ], function (Plain) {
+ "use strict";
+
+ class Broadcast extends Plain {
+
+ }
+
+ return Broadcast
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Color.js b/files_wcf/js/Bastelstu.be/Chat/Command/Color.js
new file mode 100644
index 0000000..4be3e38
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Color.js
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../Command'
+ , '../Parser'
+ ], function (Command, Parser) {
+ "use strict";
+
+ class Color extends Command {
+ getParameterParser() {
+ // Either match a color in hexadecimal RGB notation or a color name (just letters)
+ const color = Parser.F.try(Parser.RGBHex.map(color => ({ type: 'hex', value: color })))
+ .or(new Parser.X().word().map(word => ({ type: 'word', value: word })))
+
+ // Either match a single color or two colors separated by a space
+ return Parser.F.try(color.then(Parser.C.char(' ').thenRight(color))).or(color.map(item => [ item ]))
+ }
+ }
+
+ return Color
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Info.js b/files_wcf/js/Bastelstu.be/Chat/Command/Info.js
new file mode 100644
index 0000000..d204766
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Info.js
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../Command'
+ , '../Parser'
+ ], function (Command, Parser) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'ProfileStore' ]
+ class Info extends Command {
+ constructor(profileStore, id) {
+ super(id)
+ this.profileStore = profileStore
+ }
+
+ getParameterParser() {
+ return Parser.Username.map(username => ({ username }))
+ }
+
+ * autocomplete(parameterString) {
+ for (const userID of this.profileStore.getLastActivity()) {
+ const user = this.profileStore.get(userID)
+ if (!user.username.startsWith(parameterString)) continue
+
+ yield `"${user.username.replace(/"/g, '""')}" `
+ }
+ }
+ }
+ Info.DEPENDENCIES = DEPENDENCIES
+
+ return Info
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Me.js b/files_wcf/js/Bastelstu.be/Chat/Command/Me.js
new file mode 100644
index 0000000..5a47523
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Me.js
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Plain', '../Parser' ], function (Plain, Parser) {
+ "use strict";
+
+ class Me extends Plain {
+ getParameterParser() {
+ return Parser.Rest1.map(text => ({ text }))
+ }
+ }
+
+ return Me
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Mute.js b/files_wcf/js/Bastelstu.be/Chat/Command/Mute.js
new file mode 100644
index 0000000..393a72c
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Mute.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 3
+ * or later of the General Public License.
+ */
+
+define([ './_Suspension' ], function (Suspension) {
+ "use strict";
+
+ class Mute extends Suspension {
+
+ }
+
+ return Mute
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Plain.js b/files_wcf/js/Bastelstu.be/Chat/Command/Plain.js
new file mode 100644
index 0000000..2c0bb4e
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Plain.js
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../Command'
+ , '../Parser'
+ , 'WoltLabSuite/Core/StringUtil'
+ ], function (Command, Parser, StringUtil) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'ProfileStore' ]
+ class Plain extends Command {
+ constructor(profileStore, id) {
+ super(id)
+ this.profileStore = profileStore
+ }
+
+ getParameterParser() {
+ return Parser.Rest1
+ .map(StringUtil.escapeHTML.bind(StringUtil))
+ .map(text => ({ text }))
+ }
+
+ * autocomplete(parameterString) {
+ const parts = parameterString.split(/ /)
+ const lastWord = parts.pop().toLowerCase()
+
+ if (lastWord === '') {
+ return
+ }
+
+ for (const userID of this.profileStore.getLastActivity()) {
+ const user = this.profileStore.get(userID)
+ const username = user.username.toLowerCase()
+ if (!username.startsWith(parameterString) && !username.startsWith(lastWord.replace(/^@/, ''))) continue
+
+ yield `${parts.concat([ lastWord.startsWith('@') ? `@${user.username}` : user.username ]).join(' ')} `
+ }
+ }
+ }
+ Plain.DEPENDENCIES = DEPENDENCIES
+
+ return Plain
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Team.js b/files_wcf/js/Bastelstu.be/Chat/Command/Team.js
new file mode 100644
index 0000000..f90a467
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Team.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Plain' ], function (Plain) {
+ "use strict";
+
+ class Team extends Plain {
+
+ }
+
+ return Team
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Temproom.js b/files_wcf/js/Bastelstu.be/Chat/Command/Temproom.js
new file mode 100644
index 0000000..5f8ffaa
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Temproom.js
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../Command'
+ , '../Parser'
+ ], function (Command, Parser) {
+ "use strict";
+
+ class Temproom extends Command {
+ getParameterParser() {
+ const Create = Parser.C.string('create').thenReturns({ type: 'create' })
+ const Invite = Parser.C.string('invite').thenLeft(Parser.Whitespace.rep()).thenRight(Parser.Username).map((username) => {
+ return { type: 'invite'
+ , username
+ }
+ })
+ const Delete = Parser.C.string('delete').thenReturns({ type: 'delete' })
+
+ return Create.or(Invite).or(Delete)
+ }
+
+ * autocomplete(parameterString) {
+ const Create = Parser.C.string('create')
+ const Invite = Parser.C.string('invite')
+ const Delete = Parser.C.string('delete')
+
+ const subcommandDone = Create.or(Invite).or(Delete).thenLeft(Parser.Whitespace)
+
+ const subcommandCheck = subcommandDone.parse(Parser.Streams.ofString(parameterString))
+ if (subcommandCheck.isAccepted()) {
+ return
+ }
+
+ yield * [ 'create', 'invite ', 'delete' ].filter(item => item.startsWith(parameterString))
+ }
+ }
+
+ return Temproom
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Unban.js b/files_wcf/js/Bastelstu.be/Chat/Command/Unban.js
new file mode 100644
index 0000000..df3011e
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Unban.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 3
+ * or later of the General Public License.
+ */
+
+define([ './_Unsuspension' ], function (Unsuspension) {
+ "use strict";
+
+ class Unban extends Unsuspension {
+
+ }
+
+ return Unban
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Unmute.js b/files_wcf/js/Bastelstu.be/Chat/Command/Unmute.js
new file mode 100644
index 0000000..b8b0828
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Unmute.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 3
+ * or later of the General Public License.
+ */
+
+define([ './_Unsuspension' ], function (Unsuspension) {
+ "use strict";
+
+ class Unmute extends Unsuspension {
+
+ }
+
+ return Unmute
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Where.js b/files_wcf/js/Bastelstu.be/Chat/Command/Where.js
new file mode 100644
index 0000000..2dd9d49
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Where.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../Command'
+ , '../Parser'
+ ], function (Command, Parser) {
+ "use strict";
+
+ class Where extends Command {
+ getParameterParser() {
+ return Parser.F.eos
+ }
+ }
+
+ return Where
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/Whisper.js b/files_wcf/js/Bastelstu.be/Chat/Command/Whisper.js
new file mode 100644
index 0000000..8ff90b9
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/Whisper.js
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../Parser'
+ , './Plain'
+ ], function (Parser, Plain) {
+ "use strict";
+
+ class Whisper extends Plain {
+ getParameterParser() {
+ return Parser.Username.thenLeft(Parser.Whitespace.rep()).then(super.getParameterParser()).map(([ username, object ]) => {
+ object.username = username
+
+ return object
+ })
+ }
+
+ * autocomplete(parameterString) {
+ const usernameDone = Parser.Username.thenLeft(Parser.Whitespace).parse(Parser.Streams.ofString(parameterString))
+
+ if (usernameDone.isAccepted()) {
+ yield * super.autocomplete(parameterString)
+ return
+ }
+
+ for (const userID of this.profileStore.getLastActivity()) {
+ const user = this.profileStore.get(userID)
+ if (!user.username.startsWith(parameterString)) continue
+
+ yield `"${user.username.replace(/"/g, '""')}" `
+ }
+ }
+ }
+
+ return Whisper
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/_Suspension.js b/files_wcf/js/Bastelstu.be/Chat/Command/_Suspension.js
new file mode 100644
index 0000000..1417340
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/_Suspension.js
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 3
+ * or later of the General Public License.
+ */
+
+define([ '../Command'
+ , '../Parser'
+ ], function (Command, Parser) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'ProfileStore' ]
+ class Suspension extends Command {
+ constructor(profileStore, id) {
+ super(id)
+ this.profileStore = profileStore
+ }
+
+ getParameterParser() {
+ const Globally = Parser.C.string('global').thenLeft(Parser.C.string('ly').opt())
+ const Forever = Parser.C.string('forever').thenReturns(null)
+ const Timespan = Parser.N.digits.then(Parser.C.charIn('dhm')).map(function ([ span, unit ]) {
+ switch (unit) {
+ case 'd':
+ return span * 86400;
+ case 'h':
+ return span * 3600;
+ case 'm':
+ return span * 60;
+ }
+ throw new Error('Unreachable')
+ })
+ .rep()
+ .map(parts => parts.array().reduce((carry, item) => carry + item, 0))
+ .map(offset => Math.floor(Date.now() / 1000) + offset)
+
+ const Duration = Forever.or(Timespan).or(Parser.ISODate.map(item => Math.floor(item.valueOf() / 1000)))
+
+ return Parser.Username.thenLeft(Parser.Whitespace.rep())
+ .then(Globally.thenLeft(Parser.Whitespace.rep()).thenReturns(true).or(Parser.F.returns(false)))
+ .then(Duration)
+ .then(Parser.Whitespace.rep().thenRight(Parser.Rest1).or(Parser.F.eos.thenReturns(null)))
+ .map(([ username, globally, duration, reason ]) => {
+ return { username
+ , globally
+ , duration
+ , reason
+ }
+ })
+ }
+
+ * autocomplete(parameterString) {
+ const usernameDone = Parser.Username.thenLeft(Parser.Whitespace.rep()).map(username => `"${username.replace(/"/g, '""')}"`)
+ const globallyDone = usernameDone.then(Parser.C.string('global').thenLeft(Parser.C.string('ly').opt())).thenLeft(Parser.Whitespace.rep())
+
+ const usernameCheck = usernameDone.parse(Parser.Streams.ofString(parameterString))
+ if (usernameCheck.isAccepted()) {
+ const globallyCheck = globallyDone.parse(Parser.Streams.ofString(parameterString))
+ let prefix, rest
+ if (globallyCheck.isAccepted()) {
+ prefix = parameterString.substring(0, globallyCheck.offset)
+ rest = parameterString.substring(globallyCheck.offset)
+ }
+ else {
+ prefix = parameterString.substring(0, usernameCheck.offset)
+ rest = parameterString.substring(usernameCheck.offset)
+ }
+
+ if (!globallyCheck.isAccepted() && 'globally'.startsWith(rest)) {
+ yield `${prefix}globally `
+ }
+ if (/^[0-9]+$/.test(rest)) {
+ yield `${prefix}${rest}h `
+ yield `${prefix}${rest}d `
+ yield `${prefix}${rest}m `
+ }
+ if (rest === '') {
+ yield `${prefix}1h `
+ yield `${prefix}1d `
+ yield `${prefix}5m `
+ }
+
+ return
+ }
+
+ for (const userID of this.profileStore.getLastActivity()) {
+ const user = this.profileStore.get(userID)
+ if (!user.username.startsWith(parameterString)) continue
+
+ yield `"${user.username.replace(/"/g, '""')}" `
+ }
+ }
+ }
+ Suspension.DEPENDENCIES = DEPENDENCIES
+
+ return Suspension
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Command/_Unsuspension.js b/files_wcf/js/Bastelstu.be/Chat/Command/_Unsuspension.js
new file mode 100644
index 0000000..3b95a82
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Command/_Unsuspension.js
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 3
+ * or later of the General Public License.
+ */
+
+define([ '../Command'
+ , '../Parser'
+ ], function (Command, Parser) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'ProfileStore' ]
+ class Unsuspension extends Command {
+ constructor(profileStore, id) {
+ super(id)
+ this.profileStore = profileStore
+ }
+
+ getParameterParser() {
+ const Globally = Parser.C.string('global').thenLeft(Parser.C.string('ly').opt())
+
+ return Parser.Username
+ .then(Parser.Whitespace.rep().thenRight(Globally.thenReturns(true)).or(Parser.F.returns(false)))
+ .map(([ username, globally ]) => {
+ return { username
+ , globally
+ }
+ })
+ }
+
+ * autocomplete(parameterString) {
+ const usernameDone = Parser.Username.thenLeft(Parser.Whitespace.rep()).map(username => `"${username.replace(/"/g, '""')}"`)
+ const globallyDone = usernameDone.then(Parser.C.string('global').thenLeft(Parser.C.string('ly').opt())).thenLeft(Parser.Whitespace.rep())
+
+ const usernameCheck = usernameDone.parse(Parser.Streams.ofString(parameterString))
+ if (usernameCheck.isAccepted()) {
+ const globallyCheck = globallyDone.parse(Parser.Streams.ofString(parameterString))
+ let prefix, rest
+ if (globallyCheck.isAccepted()) {
+ prefix = parameterString.substring(0, globallyCheck.offset)
+ rest = parameterString.substring(globallyCheck.offset)
+ }
+ else {
+ prefix = parameterString.substring(0, usernameCheck.offset)
+ rest = parameterString.substring(usernameCheck.offset)
+ }
+
+ if (!globallyCheck.isAccepted() && 'globally'.startsWith(rest)) {
+ yield `${prefix}globally `
+ }
+ }
+
+ for (const userID of this.profileStore.getLastActivity()) {
+ const user = this.profileStore.get(userID)
+ if (!user.username.startsWith(parameterString)) continue
+
+ yield `"${user.username.replace(/"/g, '""')}" `
+ }
+ }
+ }
+ Unsuspension.DEPENDENCIES = DEPENDENCIES
+
+ return Unsuspension
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/CommandHandler.js b/files_wcf/js/Bastelstu.be/Chat/CommandHandler.js
new file mode 100644
index 0000000..da87fdd
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/CommandHandler.js
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Parser'
+ , './ParseError'
+ ], function (Parser, ParseError) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'Trigger', 'Command' ]
+ class CommandHandler {
+ constructor(triggers, commands) {
+ this.triggers = triggers
+ this.commands = commands
+ }
+
+ splitCommand(input) {
+ const result = Parser.Command.parse(Parser.Streams.ofString(input))
+
+ if (result.isAccepted()) {
+ return result.value
+ }
+ else {
+ throw new ParseError('Empty trigger')
+ }
+ }
+
+ applyCommand(command, parameterString) {
+ const result = command.getParameterParser().parse(Parser.Streams.ofString(parameterString))
+
+ if (result.isAccepted()) {
+ return result.value
+ }
+ else {
+ throw new ParseError('Could not parse', { result, parameterString })
+ }
+ }
+
+ getTriggers() {
+ return this.triggers.keys()
+ }
+
+ getCommandByTrigger(trigger) {
+ const data = this.triggers.get(trigger)
+
+ if (data == null) return null
+
+ return this.getCommandByIdentifier(...data)
+ }
+
+ getCommandByIdentifier(packageName, identifier) {
+ return this.commands[`${packageName.replace(/\./g, '-')}:${identifier}`]
+ }
+ }
+ CommandHandler.DEPENDENCIES = DEPENDENCIES
+
+ return CommandHandler
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/DataStructure/EventEmitter.js b/files_wcf/js/Bastelstu.be/Chat/DataStructure/EventEmitter.js
new file mode 100644
index 0000000..1d8db3b
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/DataStructure/EventEmitter.js
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ ], function () {
+ "use strict";
+
+ const listeners = new WeakMap()
+ const EventEmitter = function (target) {
+ Object.assign(target, {
+ on(type, listener, options = { }) {
+ if (!listeners.has(this)) {
+ listeners.set(this, new Map())
+ }
+ if (!listeners.get(this).has(type)) {
+ listeners.get(this).set(type, new Set())
+ }
+
+ if (!options.once) options.once = false
+ listeners.get(this).get(type).add({ listener, options })
+ },
+
+ off(type, listener) {
+ listeners.get(this).get(type).delete(listener)
+ },
+
+ emit(type, detail = { }) {
+ if (!listeners.has(this)) return
+ if (!listeners.get(this).has(type)) return
+
+ const set = listeners.get(this).get(type)
+
+ set.forEach((function ({ listener, options }) {
+ if (options.once) {
+ set.delete(listener)
+ }
+
+ listener({ target: this, detail })
+ }).bind(this))
+ }
+ })
+ }
+
+ return EventEmitter
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/DataStructure/LRU.js b/files_wcf/js/Bastelstu.be/Chat/DataStructure/LRU.js
new file mode 100644
index 0000000..7797fc3
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/DataStructure/LRU.js
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ ], function () {
+ "use strict";
+
+ const s = Symbol('s')
+ const start = Symbol('start')
+
+ class LRU {
+ constructor() {
+ this[s] = new Map()
+ this[start] = undefined
+ }
+
+ add(value) {
+ if (this[start] && this[start].value === value) {
+ return
+ }
+
+ if (this[s].has(value)) {
+ const entry = this[s].get(value)
+ if (entry.prev) {
+ entry.prev.next = entry.next
+ }
+ if (entry.next) {
+ entry.next.prev = entry.prev
+ }
+ }
+ const obj = { value, next: this[start], prev: undefined }
+ this[start] = obj
+ if (this[start].next) {
+ this[start].next.prev = obj
+ }
+ this[s].set(value, obj)
+ }
+
+ * [Symbol.iterator]() {
+ let current = this[start]
+ do {
+ yield current.value
+ }
+ while ((current = current.next))
+ }
+ }
+
+ return LRU
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/DataStructure/RedBlackTree/Node.js b/files_wcf/js/Bastelstu.be/Chat/DataStructure/RedBlackTree/Node.js
new file mode 100644
index 0000000..e73e144
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/DataStructure/RedBlackTree/Node.js
@@ -0,0 +1,136 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ ], function () {
+ "use strict";
+
+ class Node {
+ constructor(value) {
+ this.value = value
+ this._left = undefined
+ this._right = undefined
+ this.parent = undefined
+ this.color = 'RED'
+ }
+
+ get left() {
+ return this._left
+ }
+
+ set left(node) {
+ if (this._left) this._left.parent = undefined
+ if (node !== undefined) {
+ if (node.parent !== undefined) {
+ if (node.isLeftChild) node.parent.left = undefined
+ else if (node.isRightChild) node.parent.right = undefined
+ else throw new Error('Unreachable')
+ }
+ node.parent = this
+ }
+
+ this._left = node
+ }
+
+ get right() {
+ return this._right
+ }
+
+ set right(node) {
+ if (this._right) this._right.parent = undefined
+ if (node !== undefined) {
+ if (node.parent !== undefined) {
+ if (node.isLeftChild) node.parent.left = undefined
+ else if (node.isRightChild) node.parent.right = undefined
+ else throw new Error('Unreachable')
+ }
+ node.parent = this
+ }
+ this._right = node
+ }
+
+ get isRoot() {
+ return this.parent === undefined
+ }
+
+ get isLeaf() {
+ return this.left === undefined && this.right === undefined
+ }
+
+ get isLeftChild() {
+ if (this.parent === undefined) return false
+ return this.parent.left === this
+ }
+
+ get isRightChild() {
+ if (this.parent === undefined) return false
+ return this.parent.right === this
+ }
+
+ get grandparent() {
+ if (this.parent === undefined) return undefined
+ return this.parent.parent
+ }
+
+ get sibling() {
+ if (this.parent === undefined) return undefined
+ if (this.isLeftChild) return this.parent.right
+ return this.parent.left
+ }
+
+ get uncle() {
+ if (this.parent === undefined) return undefined
+ return this.parent.sibling
+ }
+
+ search(value) {
+ if (value === this.value) return [ 'IS', this ]
+ if (value < this.value) {
+ if (this.left !== undefined) return this.left.search(value)
+ return [ 'LEFT', this ]
+ }
+ if (value > this.value) {
+ if (this.right !== undefined) return this.right.search(value)
+ return [ 'RIGHT', this ]
+ }
+ throw new Error('Unreachable')
+ }
+
+ print(depth = 0) {
+ console.log(" ".repeat(depth) + `${this.value}: ${this.color} (Parent: ${this.parent ? this.parent.value : '-'})`)
+ if (this.left) this.left.print(depth + 1)
+ else console.log(" ".repeat(depth + 1) + '-')
+ if (this.right) this.right.print(depth + 1)
+ else console.log(" ".repeat(depth + 1) + '-')
+ }
+
+ check() {
+ if (this.left && this.left.value >= this.value) throw new Error('Invalid' + this.value);
+ if (this.right && this.right.value <= this.value) throw new Error('Invalid' + this.value);
+ if (this.color === 'RED' && ((this.left && this.left.color !== 'BLACK') || (this.right && this.right.color !== 'BLACK'))) throw new Error('Invalid' + this.value);
+
+ let leftBlacks = 1, rightBlacks = 1
+ if (this.left) {
+ leftBlacks = this.left.check()
+ }
+ if (this.right) {
+ rightBlacks = this.right.check()
+ }
+ if (leftBlacks !== rightBlacks) throw new Error('Invalid' + this.value);
+
+ if (this.color === 'BLACK') return leftBlacks + 1
+ return leftBlacks
+ }
+ }
+
+ return Node
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/DataStructure/RedBlackTree/Tree.js b/files_wcf/js/Bastelstu.be/Chat/DataStructure/RedBlackTree/Tree.js
new file mode 100644
index 0000000..9285a9c
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/DataStructure/RedBlackTree/Tree.js
@@ -0,0 +1,132 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Node' ], function (Node) {
+ "use strict";
+
+ class Tree {
+ constructor() {
+ this.root = undefined
+ }
+
+ search(value) {
+ if (this.root !== undefined) return this.root.search(value)
+ return undefined
+ }
+
+ insert(value) {
+ const node = new Node(value)
+ if (this.root === undefined) {
+ this.root = node
+ this.fix(node)
+ return [ 'RIGHT', undefined ]
+ }
+
+ const search = this.search(value)
+ const [ side, parent ] = search
+
+ if (side === 'IS') return [ side, parent.value ]
+ if (side === 'LEFT') {
+ parent.left = node
+ this.fix(node)
+ return [ side, parent.value ]
+ }
+ if (side === 'RIGHT') {
+ parent.right = node
+ this.fix(node)
+ return [ side, parent.value ]
+ }
+ throw new Error('Unreachable')
+ }
+
+ fix(N) {
+ // Case 1:
+ if (N.parent === undefined) {
+ N.color = 'BLACK'
+ return
+ }
+ // Case 2:
+ if (N.parent.color === 'BLACK') {
+ return
+ }
+
+ // Case 3:
+ const U = N.uncle
+ if (U !== undefined && U.color === 'RED') {
+ N.parent.color = 'BLACK'
+ U.color = 'BLACK'
+ const G = N.grandparent
+ G.color = 'RED'
+ this.fix(G)
+ return
+ }
+ // Case 4:
+ if (N.isRightChild && N.parent.isLeftChild) {
+ this.rotateLeft(N.parent)
+ N = N.left
+ }
+ else if (N.isLeftChild && N.parent.isRightChild) {
+ this.rotateRight(N.parent)
+ N = N.right
+ }
+
+ // Case 5
+ const G = N.grandparent
+ N.parent.color = 'BLACK'
+ G.color = 'RED'
+ if (N.isLeftChild) {
+ this.rotateRight(G)
+ }
+ else {
+ this.rotateLeft(G)
+ }
+ }
+
+ rotateLeft(N) {
+ if (N.right === undefined) return
+
+ const right = N.right
+ N.right = right.left
+ if (N.parent === undefined) {
+ this.root = right
+ }
+ else if (N.isLeftChild) {
+ N.parent.left = right
+ }
+ else if (N.isRightChild) {
+ N.parent.right = right
+ }
+
+ right.left = N
+ }
+
+ rotateRight(N) {
+ if (N.left === undefined) return
+
+ const left = N.left
+ N.left = left.right
+ if (N.parent === undefined) {
+ this.root = left
+ }
+ else if (N.isLeftChild) {
+ N.parent.left = left
+ }
+ else if (N.isRightChild) {
+ N.parent.right = left
+ }
+ left.right = N
+ }
+ }
+
+ return Tree
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/DataStructure/Throttle.js b/files_wcf/js/Bastelstu.be/Chat/DataStructure/Throttle.js
new file mode 100644
index 0000000..1e6813a
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/DataStructure/Throttle.js
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ ], function () {
+ "use strict";
+
+ class Throttler {
+ constructor(callback, delay = 125) {
+ if (!(typeof callback === 'function')) {
+ throw new Error('Callback must be a function.')
+ }
+ if (delay < 0) {
+ throw new Error('Delay must be non-negative.')
+ }
+
+ this.callback = callback
+ this._delay = delay
+
+ this.hot = false
+ this.awaiting = false
+ this.timer = null
+ this.last = Date.now()
+ }
+
+ setTimer() {
+ if (this.timer != null) {
+ clearTimeout(this.timer)
+ }
+
+ this.timer = setTimeout(_ => {
+ this.timer = null
+ this.hot = false
+
+ if (this.awaiting) {
+ this.execute()
+ }
+ }, this.delay)
+ }
+
+ execute() {
+ this.awaiting = false
+ this.hot = true
+
+ this.last = Date.now()
+
+ this.setTimer()
+ this.callback()
+ }
+
+ guardedExecute() {
+ if (this.hot) {
+ this.awaiting = true
+ }
+ else {
+ this.execute()
+ }
+ }
+
+ get delay() {
+ return this._delay
+ }
+
+ set delay(newDelay) {
+ if (this.awaiting && (Date.now() - this.last) > newDelay) {
+ this._delay = 0
+ this.setTimer()
+ }
+ else if (this.timer) {
+ this._delay = Math.max(0, newDelay - (Date.now() - this.last))
+ this.setTimer()
+ }
+
+ this._delay = newDelay
+ }
+ }
+
+ const throttle = function (callback, delay = 125) {
+ const throttler = new Throttler(callback, delay)
+ const result = throttler.guardedExecute.bind(throttler)
+ result.setDelay = function (newDelay) {
+ throttler.delay = newDelay
+ }
+ result.getDelay = function () {
+ return throttler.delay
+ }
+
+ return result
+ }
+
+ return throttle
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Helper.js b/files_wcf/js/Bastelstu.be/Chat/Helper.js
new file mode 100644
index 0000000..4f591f9
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Helper.js
@@ -0,0 +1,378 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Date/Util'
+ , 'WoltLabSuite/Core/Language'
+ ], function (DateUtil, Language) {
+ "use strict";
+
+ class Helper {
+ static deepFreeze(obj) {
+ const propNames = Object.getOwnPropertyNames(obj)
+
+ propNames.forEach(function (name) {
+ let prop = obj[name]
+
+ if (typeof prop === 'object' && prop !== null) Helper.deepFreeze(prop)
+ })
+
+ return Object.freeze(obj)
+ }
+
+ /**
+ * Returns true if the given element is an input[type=text],
+ * input[type=password] or textarea.
+ *
+ * @param {Node} element
+ * @returns {boolean}
+ */
+ static isInput(element) {
+ if (element.tagName === 'INPUT') {
+ if (element.getAttribute('type') !== 'text' && element.getAttribute('type') !== 'password') {
+ return false
+ }
+ }
+ else if (element.tagName !== 'TEXTAREA') {
+ return false
+ }
+
+ return true
+ }
+
+ static throttle(fn, threshold = 250, scope) {
+ let last = 0
+ let deferTimer = null
+
+ return function() {
+ const now = new Date().getTime()
+ const args = arguments
+ const context = scope || this
+
+ if (last && (now < (last + threshold))) {
+ clearTimeout(deferTimer)
+
+ return deferTimer = setTimeout(function() {
+ last = now
+
+ return fn.apply(context, args)
+ }, threshold)
+ }
+ else {
+ last = now
+
+ return fn.apply(context, args)
+ }
+ }
+ }
+
+ /**
+ * Returns the caret position of the given element. If the element
+ * is not an input or textarea element -1 is returned.
+ *
+ * @param {Node} element
+ * @returns {number}
+ */
+ static getCaret(element) {
+ if (!Helper.isInput(element)) throw new Error('Unsupported element')
+
+ let position = 0
+
+ if (element.selectionStart) {
+ position = element.selectionStart
+ }
+
+ return position
+ }
+
+ static setCaret(element, position) {
+ if (!Helper.isInput(element)) throw new Error('Unsupported element')
+
+ if (element.selectionStart) {
+ element.focus()
+ element.setSelectionRange(position, position)
+ }
+ }
+
+ static wrapElement(element, wrapper) {
+ wrapper = wrapper || document.createElement('div')
+
+ if (element.nextSibling) {
+ element.parentNode.insertBefore(wrapper, element.nextSibling)
+ }
+ else {
+ element.parentNode.appendChild(wrapper)
+ }
+
+ return wrapper.appendChild(element)
+ }
+
+ // Based on https://github.com/alexdunphy/flexText
+ static makeFlexible(textarea) {
+ if (textarea.tagName !== 'TEXTAREA') {
+ throw new Error(`Unsupported element type: ${textarea.tagName}`)
+ }
+
+ const pre = document.createElement('pre')
+ const span = document.createElement('span')
+
+ const mirror = function () {
+ span.textContent = textarea.value
+ }
+
+ if (!textarea.parentNode.classList.contains('flexibleTextarea')) {
+ Helper.wrapElement(textarea)
+ textarea.parentNode.classList.add('flexibleTextarea')
+ }
+
+ textarea.classList.add('flexibleTextareaContent')
+ pre.classList.add('flexibleTextareaMirror')
+
+ pre.appendChild(span)
+ pre.appendChild(document.createElement('br'))
+ textarea.parentNode.insertBefore(pre, textarea)
+
+ textarea.addEventListener('input', mirror)
+ mirror()
+ }
+
+ static getCircularArray(size) {
+ class CircularArray extends Array {
+ constructor(size) {
+ super()
+
+ Object.defineProperty(this, 'size', { enumerable: false
+ , value: size
+ , writable: false
+ , configurable: false
+ });
+ }
+
+ push() {
+ super.push.apply(this, arguments)
+
+ if (this.length > this.size) {
+ super.shift()
+ }
+
+ return this.length;
+ }
+
+ unshift() {
+ super.unshift.apply(this, arguments)
+
+ if (this.length > this.size) {
+ super.pop()
+ }
+
+ return this.length;
+ }
+
+ first() {
+ return this[0]
+ }
+
+ last() {
+ return this[this.length - 1]
+ }
+ }
+
+ return new CircularArray(size)
+ }
+
+ static intToRGBHex(integer) {
+ const r = ((integer >> 16) & 0xFF).toString(16)
+ const g = ((integer >> 8) & 0xFF).toString(16)
+ const b = ((integer >> 0) & 0xFF).toString(16)
+
+ const rr = r.length == 1 ? `0${r}` : r
+ const gg = g.length == 1 ? `0${g}` : g
+ const bb = b.length == 1 ? `0${b}` : b
+
+ return `#${rr}${gg}${bb}`
+ }
+
+ /**
+ * Returns the markup of a `time` element based on the given date just like a `time`
+ * element created by `wcf\system\template\plugin\TimeModifierTemplatePlugin`.
+ *
+ * @param {Date} date displayed date
+ * @returns {string} `time` element
+ */
+ static getTimeElementHTML(date) {
+ const isFutureDate = date.getTime() > new Date().getTime()
+ let dateTime = ''
+
+ if (isFutureDate) {
+ dateTime = DateUtil.formatDateTime(date)
+ }
+
+ // WSC 3.1
+ if (typeof DateUtil.getTimeElement === 'function') {
+ const elem = DateUtil.getTimeElement(date)
+
+ // Work around a bug in DateUtil paired with Time/Relative
+ if (isFutureDate) elem.innerText = dateTime
+
+ return elem.outerHTML
+ }
+
+ return `${dateTime} `
+ }
+
+ /**
+ * Returns whether the supplied selection range covers the whole text inside the given node
+ *
+ * Source: https://stackoverflow.com/a/27686686/1112384
+ *
+ * @param {Range} range Selection range
+ * @param {Node}
+ * @return {Boolean}
+ */
+ static rangeSpansTextContent(range, node) {
+ const treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT)
+
+ let firstTextNode, lastTextNode
+ while (treeWalker.nextNode()) {
+ if (treeWalker.currentNode.nodeValue.trim() === '') continue
+
+ if (!firstTextNode) {
+ firstTextNode = treeWalker.currentNode
+ }
+
+ lastTextNode = treeWalker.currentNode
+ }
+
+ const nodeRange = range.cloneRange()
+ if (firstTextNode) {
+ nodeRange.setStart(firstTextNode, 0)
+ nodeRange.setEnd(lastTextNode, lastTextNode.length)
+ }
+ else {
+ nodeRange.selectNodeContents(node)
+ }
+
+ const bp1 = range.compareBoundaryPoints(Range.START_TO_START, nodeRange)
+ const bp2 = range.compareBoundaryPoints(Range.END_TO_END, nodeRange)
+
+ return bp1 < 1 && bp2 > -1
+ }
+
+ /**
+ * Returns the text of a node and its children.
+ *
+ * @see {@link https://github.com/WoltLab/WCF/blob/a20be4267fc711299d6bde7c34a8b36199ae393f/wcfsetup/install/files/js/WCF.Message.js#L1180-L1264}
+ * @param {Node} node
+ * @return {String}
+ */
+ static getTextContent(node) {
+ const acceptNode = node => {
+ if (node instanceof Element) {
+ if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') return NodeFilter.FILTER_REJECT
+ }
+
+ return NodeFilter.FILTER_ACCEPT
+ }
+
+ let out = ''
+
+ const flags = NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT
+ const treeWalker = document.createTreeWalker(node, flags, { acceptNode })
+
+ const ignoredLinks = [ ]
+
+ while (treeWalker.nextNode()) {
+ const node = treeWalker.currentNode
+
+ if (node instanceof Text) {
+ if (node.parentNode.tagName === 'A' && ignoredLinks.indexOf(node.parentNode) >= 0) {
+ continue
+ }
+
+ out += node.nodeValue.replace(/\n/g, '')
+ }
+ else {
+ switch (node.tagName) {
+ case 'IMG': {
+ const alt = node.getAttribute('alt')
+
+ if (node.classList.contains('smiley')) {
+ out += ` ${alt} `
+ }
+ else if (alt && alt !== '') {
+ out += ` ${alt} [Image ${node.src}] `
+ }
+ else {
+ out += ` [Image ${node.src}] `
+ }
+ break }
+
+ case 'BR':
+ case 'LI':
+ case 'UL':
+ case 'DIV':
+ case 'TR':
+ out += '\n'
+ break
+
+ case 'TH':
+ case 'TD':
+ out += '\t'
+ break
+
+ case 'P':
+ out += '\n\n'
+ break
+
+ case 'A': {
+ let link = node.href
+ const text = node.textContent.trim()
+
+ // handle named anchors
+ if (text !== '' && text !== node.href) {
+ ignoredLinks.push(node)
+
+ let truncated = false
+
+ if (text.indexOf('\u2026') >= 0) {
+ const parts = text.split(/\u2026/)
+
+ if (parts.length === 2) {
+ truncated = node.href.startsWith(parts[0]) && node.href.endsWith(parts[1])
+ }
+ }
+
+ if (!truncated) {
+ link = `${node.textContent} [URL:${node.href}]`
+ }
+ }
+
+ out += link
+ break }
+ }
+ }
+ }
+
+ return out
+ }
+ }
+
+ return Helper
+});
+
diff --git a/files_wcf/js/Bastelstu.be/Chat/LocalStorage.js b/files_wcf/js/Bastelstu.be/Chat/LocalStorage.js
new file mode 100644
index 0000000..d310625
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/LocalStorage.js
@@ -0,0 +1,201 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Core', './LocalStorageEmulator' ], function (Core, LocalStorageEmulator) {
+ 'use strict';
+
+ const DEPENDENCIES = [ ]
+ class LocalStorage {
+ constructor(subprefix) {
+ this.subprefix = subprefix
+ this.hasLocalStorage = false
+ this.setupStorage()
+ }
+
+ static isQuotaExceeded(error) {
+ return error instanceof DOMException && (
+ // everything except Firefox
+ error.code === 22 ||
+ // Firefox
+ error.code === 1014 ||
+ // everything except Firefox
+ error.name === 'QuotaExceededError' ||
+ // Firefox
+ error.name === 'NS_ERROR_DOM_QUOTA_REACHED')
+ }
+
+ static isAvailable() {
+ try {
+ const x = '__storage_test__'
+ window.localStorage.setItem(x, x)
+ window.localStorage.removeItem(x)
+ return true
+ }
+ catch (error) {
+ return LocalStorage.isQuotaExceeded(error)
+ }
+ }
+
+ setupStorage() {
+ if (LocalStorage.isAvailable()) {
+ this.storage = window.localStorage
+ this.hasLocalStorage = true
+ }
+ else {
+ console.info('Falling back to in-memory local storage emulation')
+ this.storage = new LocalStorageEmulator()
+ }
+ }
+
+ /**
+ * Return the prefix to use for the local storage
+ *
+ * @returns {string} The storage prefix
+ */
+ get storagePrefix() {
+ let prefix = ''
+
+ // WSC 3.1
+ if (typeof Core.getStoragePrefix === 'function') {
+ prefix = Core.getStoragePrefix()
+ }
+
+ return `${prefix}be.bastelstu.Chat.${this.subprefix}`
+ }
+
+ /**
+ * Calls listener, whenever key changes.
+ *
+ * @param {string} key The key to observe.
+ * @param {*} listener The listener to call.
+ */
+ observe(key, listener) {
+ window.addEventListener('storage', (event) => {
+ if (event.storageArea !== window.localStorage) return
+ if (event.key !== `${this.storagePrefix}${key}`) return
+
+ listener(event)
+ })
+ }
+
+ /**
+ * Sets the value of a setting
+ *
+ * @param {string} key The key of the setting to set
+ * @param {string} value The new value of the setting
+ * @returns {string}
+ */
+ set(key, value) {
+ try {
+ this.storage.setItem(`${this.storagePrefix}${key}`, JSON.stringify(value))
+ }
+ catch (error) {
+ if (!LocalStorage.isQuotaExceeded(error)) throw error
+
+ console.warn(`Your localStorage has exceeded the size quota for this domain`)
+ console.warn(`We are falling back to an in-memory storage, this does not persist data!`)
+ console.error(error)
+
+ const storage = new LocalStorageEmulator()
+
+ // Make a copy of the current localStorage
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i)
+ const value = localStorage.getItem(key)
+ storage.setItem(key, value)
+ }
+
+ // Replace the localStorage with our in-memory variant
+ this.storage = storage
+ }
+
+ return this.get(key)
+ }
+
+ /**
+ * Retrieves the value of a setting
+ *
+ * @param {string} key The key of the setting to retrieve
+ * @returns {string} The current value of the setting
+ */
+ get(key) {
+ const value = this.storage.getItem(`${this.storagePrefix}${key}`)
+
+ if (value == null) return null
+ return JSON.parse(value)
+ }
+
+ /**
+ * Returns whether the given setting has a value.
+ *
+ * @param {string} key The key of the setting to check
+ * @returns {boolean}
+ */
+ has(key) {
+ return this.storage.getItem(`${this.storagePrefix}${key}`) != null
+ }
+
+ /**
+ * Removes a single setting
+ *
+ * @param {string} key The key of the setting to remove
+ * @returns {string} The last value of the provided setting
+ */
+ remove(key) {
+ const value = this.get(key)
+ const storageKey = `${this.storagePrefix}${key}`
+
+ this.storage.removeItem(storageKey)
+
+ return value
+ }
+
+ /**
+ * Removes all of the chat settings with the right prefix
+ * and try to use the real localStorage again, if the qouta isn’t exceeded anymore
+ */
+ clear() {
+ const _clear = (target) => {
+ for (let key in target) {
+ if (!key.startsWith(this.storagePrefix) || !target.hasOwnProperty(key)) continue
+
+ target.removeItem(key)
+ }
+ }
+
+ if (this.hasLocalStorage && this.storage instanceof LocalStorageEmulator) {
+ try {
+ // Try to clear the real localStorage
+ _clear(localStorage)
+
+ // Check if we can use the localStorage again
+ const x = '__storage_test__'
+ window.localStorage.setItem(x, x)
+ window.localStorage.removeItem(x)
+
+ // It should be safe to use the localStorage again, as the storage
+ // of this instance (given by the prefix) has been cleared completely
+ this.storage = localStorage
+
+ console.log('Switched back to using the localStorage')
+ }
+ catch (error) { /* no we can’t */ }
+ }
+
+ _clear(this.storage)
+ }
+ }
+ LocalStorage.DEPENDENCIES = DEPENDENCIES
+
+ return LocalStorage
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/LocalStorageEmulator.js b/files_wcf/js/Bastelstu.be/Chat/LocalStorageEmulator.js
new file mode 100644
index 0000000..2eb918a
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/LocalStorageEmulator.js
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ ], function () {
+ 'use strict';
+
+ class LocalStorageEmulator {
+ constructor () {
+ this._data = new Map()
+ return new Proxy(this, {
+ get(target, property) {
+ // Check if the property exists on the object or its prototype
+ if (target.hasOwnProperty(property) || Object.getPrototypeOf(target)[property]) {
+ return target[property]
+ }
+
+ // Otherwise proxy to the underlying map
+ return target.getItem(property)
+ },
+ set(target, property, value) {
+ // Check if the property exists on the object or its prototype
+ if (target.hasOwnProperty(property) || Object.getPrototypeOf(target)[property]) {
+ target[property] = value
+ }
+ else {
+ // Proxy to the underlying map
+ target.setItem(property, value)
+ }
+ },
+ has(target, property) {
+ return target.hasOwnProperty(property) // check the properties of the object
+ || Object.getPrototypeOf(target)[property] // check its prototype
+ || target._data.has(property) // check the underlying map
+ },
+ ownKeys(target) {
+ // Proxy to the underlying map
+ return Array.from(target._data.keys())
+ },
+ getOwnPropertyDescriptor(target, property) {
+ // Make the properties of the map visible
+ return {
+ enumerable: true,
+ configurable: true
+ }
+ }
+ })
+ }
+
+ get length() {
+ return this._data.size
+ }
+
+ key(n = 0) {
+ return Array.from(this._data.keys())[n]
+ }
+
+ getItem(key) {
+ return this._data.get(key)
+ }
+
+ setItem(key, value) {
+ this._data.set(key, value)
+ }
+
+ removeItem(key) {
+ this._data.delete(key)
+ }
+
+ clear() {
+ this._data.clear()
+ }
+
+ * [Symbol.iterator]() {
+ yield * this._data.values()
+ }
+ }
+
+ return LocalStorageEmulator
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Log.js b/files_wcf/js/Bastelstu.be/Chat/Log.js
new file mode 100644
index 0000000..e864eb5
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Log.js
@@ -0,0 +1,223 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './console'
+ , 'Bastelstu.be/bottle'
+ , 'WoltLabSuite/Core/Core'
+ , './Message'
+ , './Messenger'
+ , './ProfileStore'
+ , './Room'
+ , './Template'
+ , './Ui/Log'
+ , './Ui/MessageStream'
+ , './Ui/MessageActions/Delete'
+ ], function (console, Bottle, Core, Message, Messenger, ProfileStore, Room, Template, Ui, UiMessageStream, UiMessageActionDelete) {
+ "use strict";
+
+ const loader = Symbol('loader')
+
+ class Log {
+ constructor(params, config) {
+ console.debug('ChatLog.constructor', 'Constructing …')
+
+ this.config = config
+
+ this.sessionID = Core.getUuid()
+ this.bottle = new Bottle()
+ this.bottle.value('bottle', this.bottle)
+ this.bottle.value('config', config)
+ this.bottle.constant('sessionID', this.sessionID)
+ this.bottle.constant('roomID', params.roomID)
+
+ // Register chat components
+ this.service('Messenger', Messenger)
+ this.service('ProfileStore', ProfileStore)
+ this.service('Room', Room)
+
+ // Register UI components
+ this.service('Ui', Ui)
+ this.service('UiMessageActionDelete', UiMessageActionDelete)
+ this.service('UiMessageStream', UiMessageStream)
+
+ // Register Models
+ this.bottle.instanceFactory('Message', (container, m) => {
+ return new Message(container.MessageType, m)
+ })
+
+ // Register Templates
+ const selector = [ '[type="x-text/template"]'
+ , '[data-application="be.bastelstu.chat"]'
+ , '[data-template-name]'
+ ].join('')
+ const templates = elBySelAll(selector)
+ templates.forEach((function (template) {
+ this.bottle.factory(`Template.${elData(template, 'template-name')}`, function (container) {
+ const includeNames = (elData(template, 'template-includes') || '').split(/ /).filter(item => item !== "")
+ const includes = { }
+ includeNames.forEach(item => includes[item] = container[item])
+
+ return new Template(template.textContent, includes)
+ })
+ }).bind(this))
+
+ // Register MessageTypes
+ const messageTypes = Object.entries(this.config.messageTypes)
+ messageTypes.forEach(([ objectType, messageType ]) => {
+ const MessageType = require(messageType.module)
+
+ this.bottle.factory(`MessageType.${objectType.replace(/\./g, '-')}`, _ => {
+ const deps = this.bottle.digest(MessageType.DEPENDENCIES || [])
+
+ return new MessageType(...deps, objectType)
+ })
+ })
+
+ this.knows = { from: undefined
+ , to: undefined
+ }
+
+ this.messageSinks = new Set()
+
+ this.params = params
+
+ this.pulling = false
+ }
+
+ service(name, _constructor, args = [ ]) {
+ this.bottle.factory(name, function (container) {
+ const deps = (_constructor.DEPENDENCIES || [ ]).map(dep => container[dep])
+
+ return new _constructor(...deps, ...args)
+ })
+ }
+
+ async bootstrap() {
+ console.debug('ChatLog.bootstrap', 'Initializing …')
+
+ this.ui = this.bottle.container.Ui
+ this.ui.bootstrap()
+
+ this.registerMessageSink(this.bottle.container.UiMessageStream)
+
+ if (this.params.messageID > 0) {
+ await Promise.all([ this.pull(undefined, this.params.messageID)
+ , this.pull(this.params.messageID + 1)
+ ])
+ }
+ else {
+ await this.pull()
+ }
+
+ this.bottle.container.UiMessageStream.on('nearTop', this.pullOlder.bind(this))
+ this.bottle.container.UiMessageStream.on('reachedTop', this.pullOlder.bind(this))
+ this.bottle.container.UiMessageStream.on('nearBottom', this.pullNewer.bind(this))
+ this.bottle.container.UiMessageStream.on('reachedBottom', this.pullNewer.bind(this))
+
+ const element = document.querySelector(`#message-${this.params.messageID}`)
+
+ // Force changing the hash to trigger a new lookup of the element.
+ // At least Chrome won’t target an element if it is not in the DOM
+ // on the initial page load with an hash set.
+ window.location.hash = ''
+ window.location.hash = `message-${this.params.messageID}`
+
+ if (element && element.scrollIntoView) element.scrollIntoView()
+
+ return this
+ }
+
+ registerMessageSink(sink) {
+ if (typeof sink.ingest !== 'function') {
+ throw new Error('The given sink does not provide a .ingest function.')
+ }
+
+ this.messageSinks.add(sink)
+ }
+
+ unregisterMessageSink(sink) {
+ this.messageSinks.delete(sink)
+ }
+
+ async pull(from, to) {
+ try {
+ await this.handlePull(await this.performPull(from, to))
+ }
+ catch (e) {
+ this.handleError(e)
+ }
+ }
+
+ async pullOlder() {
+ if (this.pulling) return
+ if (this.knows.from <= 1) return
+
+ this.pulling = true
+
+ await this.pull(undefined, this.knows.from - 1)
+
+ this.pulling = false
+ }
+
+ async pullNewer() {
+ if (this.pulling) return
+
+ this.pulling = true
+
+ await this.pull(this.knows.to + 1)
+
+ this.pulling = false
+ }
+
+ async performPull(from = undefined, to = undefined) {
+ console.debug('ChatLog.performPull', `Pulling new messages; from: ${from !== undefined ? from : 'undefined'}, to: ${to !== undefined ? to : 'undefined'}`)
+
+ return this.bottle.container.Messenger.pull(from, to, true)
+ }
+
+ handleError(error) {
+ console.debug('ChatLog.handleError', `Request failed`)
+ console.debugException(error)
+ }
+
+ async handlePull(payload) {
+ console.debug('ChatLog.handlePull', payload)
+
+ // Null range: No messages satisfy the constraints
+ if (payload.from > payload.to) return
+
+ let messages = payload.messages
+
+ if (this.knows.from !== undefined && this.knows.to !== undefined) {
+ messages = messages.filter((function (message) {
+ return !(this.knows.from <= message.messageID && message.messageID <= this.knows.to)
+ }).bind(this))
+ }
+
+ if (this.knows.from === undefined || payload.from < this.knows.from) this.knows.from = payload.from
+ if (this.knows.to === undefined || payload.to > this.knows.to) this.knows.to = payload.to
+
+ await Promise.all(messages.map((message) => {
+ return message.getMessageType().preProcess(message)
+ }))
+
+ const userIDs = messages.map(message => message.userID)
+ .filter(userID => userID !== null)
+ await this.bottle.container.ProfileStore.ensureUsersByIDs(userIDs)
+
+ this.messageSinks.forEach(sink => sink.ingest(messages))
+ }
+ }
+
+ return Log
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Message.js b/files_wcf/js/Bastelstu.be/Chat/Message.js
new file mode 100644
index 0000000..661a259
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Message.js
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Helper'
+ , 'WoltLabSuite/Core/Date/Util'
+ , 'WoltLabSuite/Core/User'
+ ], function (Helper, DateUtil, User) {
+ "use strict";
+
+ const m = Symbol('message')
+
+ class Message {
+ constructor(MessageType, message) {
+ this[m] = Helper.deepFreeze(message)
+ this.MessageType = MessageType
+ }
+
+ get messageID() {
+ return this[m].messageID
+ }
+
+ get objectType() {
+ return this[m].objectType
+ }
+
+ getMessageType() {
+ return this.MessageType[this.objectType.replace(/\./g, '-')]
+ }
+
+ get time() {
+ return this[m].time
+ }
+
+ get formattedTime() {
+ return DateUtil.format(this.date, 'H:i:s')
+ }
+
+ get date() {
+ return new Date(this[m].time * 1000)
+ }
+
+ get link() {
+ return this[m].link
+ }
+
+ get userID() {
+ return this[m].userID
+ }
+
+ get username() {
+ return this[m].username
+ }
+
+ get isIgnored() {
+ return this[m].isIgnored
+ }
+
+ get isDeleted() {
+ return this[m].isDeleted
+ }
+
+ get payload() {
+ return this[m].payload
+ }
+
+ isOwnMessage() {
+ return this.userID === User.userId
+ }
+
+ wrap() {
+ return { message: this[m] }
+ }
+
+ toJSON() {
+ return this[m]
+ }
+ }
+
+ return Message
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType.js b/files_wcf/js/Bastelstu.be/Chat/MessageType.js
new file mode 100644
index 0000000..152209e
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType.js
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Date/Util'
+ , 'WoltLabSuite/Core/Language'
+ , 'WoltLabSuite/Core/Dom/Util'
+ , 'Bastelstu.be/Chat/User'
+ ], function (DateUtil, Language, DomUtil, User) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'ProfileStore', 'Template' ]
+ class MessageType {
+ constructor(profileStore, templates, objectType) {
+ this.profileStore = profileStore
+ this.templates = templates
+
+ this.objectType = objectType
+ }
+
+ shouldUpdateUserList() {
+ return false
+ }
+
+ getReferencedUsers(message) {
+ if (message.userID === null) return [ ]
+
+ return [ message.userID ]
+ }
+
+ preProcess(message) {
+
+ }
+
+ preRender(message) {
+
+ }
+
+ render(message) {
+ const variables = { message
+ , users: this.profileStore
+ , author: this.profileStore.get(message.userID)
+ , DateUtil
+ , Language
+ }
+
+ if (variables.author == null) {
+ variables.author = User.getGuest(message.username)
+ }
+
+ return DomUtil.createFragmentFromHtml(this.templates[message.objectType.replace(/\./g, '-')].fetch(variables))
+ }
+
+ renderPlainText(message) {
+ return false
+ }
+
+ joinable(messageA, messageB) {
+ return false
+ }
+ }
+ MessageType.DEPENDENCIES = DEPENDENCIES
+
+ return MessageType
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Away.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Away.js
new file mode 100644
index 0000000..eb11c35
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Away.js
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../MessageType' ], function (MessageType) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'ProfileStore', 'roomID' ].concat(MessageType.DEPENDENCIES || [ ])
+ class Away extends MessageType {
+ constructor(profileStore, roomID, ...superDeps) {
+ super(...superDeps)
+
+ this.profileStore = profileStore
+ this.roomID = roomID
+ }
+
+ render(message) {
+ const isSilent = message.payload.rooms.find(room => room.roomID === this.roomID).isSilent
+
+ if (!isSilent) {
+ return super.render(message)
+ }
+ else {
+ return false
+ }
+ }
+
+ shouldUpdateUserList(message) {
+ return true
+ }
+
+ preProcess(message) {
+ this.profileStore.expire(message.userID)
+ }
+ }
+ Away.DEPENDENCIES = DEPENDENCIES
+
+ return Away
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Back.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Back.js
new file mode 100644
index 0000000..420f79a
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Back.js
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../MessageType' ], function (MessageType) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'ProfileStore', 'roomID' ].concat(MessageType.DEPENDENCIES || [ ])
+ class Back extends MessageType {
+ constructor(profileStore, roomID, ...superDeps) {
+ super(...superDeps)
+
+ this.profileStore = profileStore
+ this.roomID = roomID
+ }
+
+ render(message) {
+ const isSilent = message.payload.rooms.find(room => room.roomID === this.roomID).isSilent
+
+ if (!isSilent) {
+ return super.render(message)
+ }
+ else {
+ return false
+ }
+ }
+
+ shouldUpdateUserList(message) {
+ return true
+ }
+
+ preProcess(message) {
+ this.profileStore.expire(message.userID)
+ }
+ }
+ Back.DEPENDENCIES = DEPENDENCIES
+
+ return Back
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Broadcast.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Broadcast.js
new file mode 100644
index 0000000..9574f88
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Broadcast.js
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Plain' ], function (Plain) {
+ "use strict";
+
+ class Broadcast extends Plain {
+ renderPlainText(message) {
+ return `[📢] ${message.payload.plaintextMessage}`
+ }
+ }
+
+ return Broadcast
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/ChatUpdate.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/ChatUpdate.js
new file mode 100644
index 0000000..987f77c
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/ChatUpdate.js
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../MessageType' ], function (MessageType) {
+ "use strict";
+
+ class ChatUpdate extends MessageType {
+ preRender(message) {
+ // TODO: hcf()?
+ }
+
+ render(message) {
+ return false
+ }
+ }
+
+ return ChatUpdate
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Color.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Color.js
new file mode 100644
index 0000000..fd6c494
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Color.js
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../MessageType' ], function (MessageType) {
+ "use strict";
+
+ class Color extends MessageType {
+ render(message) {
+ if (message.isOwnMessage()) {
+ return super.render(message)
+ }
+ else {
+ return false
+ }
+ }
+
+ shouldUpdateUserList(message) {
+ return true
+ }
+
+ preProcess(message) {
+ this.profileStore.expire(message.userID)
+ }
+ }
+
+ return Color
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Info.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Info.js
new file mode 100644
index 0000000..37c9e45
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Info.js
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Dom/Traverse'
+ , 'WoltLabSuite/Core/Language'
+ , '../Helper'
+ , '../MessageType'
+ ], function (DomTraverse, Language, Helper, MessageType) {
+ "use strict";
+
+ const decorators = Symbol('decorators')
+
+ class Info extends MessageType {
+ constructor(...superArgs) {
+ super(...superArgs)
+
+ this[decorators] = new Set()
+ }
+
+ addDecorator(decorator) {
+ if (typeof decorator !== 'function') {
+ throw new TypeError('Supplied argument must be a function')
+ }
+
+ this[decorators].add(decorator)
+ }
+
+ getReferencedUsers(message) {
+ return super.getReferencedUsers(message).concat([ message.payload.user.userID ])
+ }
+
+ render(message) {
+ const rooms = message.payload.rooms.map(function (item) {
+ const aug = { lastPull: null
+ , lastPullHTML: null
+ , lastPush: null
+ , lastPushHTML: null
+ }
+
+ if (item.lastPull) {
+ aug.lastPull = new Date(item.lastPull * 1000)
+ aug.lastPullHTML = Helper.getTimeElementHTML(aug.lastPull)
+ }
+
+ if (item.lastPush) {
+ aug.lastPush = new Date(item.lastPush * 1000)
+ aug.lastPushHTML = Helper.getTimeElementHTML(aug.lastPush)
+ }
+
+ return Object.assign({ }, item, aug)
+ })
+
+ const payload = Helper.deepFreeze(
+ Array.from(this[decorators]).reduce( (payload, decorator) => decorator(payload)
+ , Object.assign({ }, message.payload, { rooms })
+ )
+ )
+
+ const fragment = super.render(new Proxy(message, {
+ get: function (target, property) {
+ if (property === 'payload') return payload
+ return target[property]
+ }
+ }))
+
+ const icon = elCreate('span')
+ icon.classList.add('icon', 'icon16', 'fa-times', 'jsTooltip', 'hideIcon')
+ icon.setAttribute('title', Language.get('wcf.global.button.hide'))
+ icon.addEventListener('click', () => elHide(DomTraverse.parentBySel(icon, '.chatMessageBoundary')))
+
+ const elem = fragment.querySelector('.chatMessage .containerList > li:first-child .containerHeadline')
+ elem.insertBefore(icon, elem.firstChild)
+
+ return fragment
+ }
+ }
+
+ return Info
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Join.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Join.js
new file mode 100644
index 0000000..c2da914
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Join.js
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../MessageType', 'WoltLabSuite/Core/Language' ], function (MessageType, Language) {
+ "use strict";
+
+ class Join extends MessageType {
+ shouldUpdateUserList(message) {
+ return true
+ }
+
+ renderPlainText(message) {
+ return '[➡️] ' + Language.get('chat.messageType.be.bastelstu.chat.messageType.join.plain', { author: { username: message.username } })
+ }
+ }
+
+ return Join
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Leave.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Leave.js
new file mode 100644
index 0000000..db5ccfe
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Leave.js
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../MessageType', 'WoltLabSuite/Core/Language' ], function (MessageType, Language) {
+ "use strict";
+
+ class Leave extends MessageType {
+ shouldUpdateUserList(message) {
+ return true
+ }
+
+ renderPlainText(message) {
+ return '[⬅️️] ' + Language.get('chat.messageType.be.bastelstu.chat.messageType.leave.plain', { author: { username: message.username } })
+ }
+ }
+
+ return Leave
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Me.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Me.js
new file mode 100644
index 0000000..fe030d1
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Me.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../MessageType' ], function (MessageType) {
+ "use strict";
+
+ class Me extends MessageType {
+
+ }
+
+ return Me
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Plain.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Plain.js
new file mode 100644
index 0000000..e001219
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Plain.js
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../MessageType' ], function (MessageType) {
+ "use strict";
+
+ class Plain extends MessageType {
+ joinable(a, b) {
+ return a.userID === b.userID
+ }
+
+ renderPlainText(message) {
+ return message.payload.plaintextMessage
+ }
+ }
+
+ return Plain
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Suspend.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Suspend.js
new file mode 100644
index 0000000..469b685
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Suspend.js
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../Helper'
+ , 'WoltLabSuite/Core/Date/Util'
+ , '../MessageType'
+ ], function (Helper, DateUtil, MessageType) {
+ "use strict";
+
+ class Suspend extends MessageType {
+ render(message) {
+ const expires = message.payload.suspension.expires !== null ? new Date(message.payload.suspension.expires * 1000) : null
+ const formattedExpires = expires !== null ? DateUtil.formatDateTime(expires) : null
+ const aug = { expires
+ , formattedExpires
+ }
+ const suspension = Object.assign({ }, message.payload.suspension, aug)
+ const payload = Helper.deepFreeze(Object.assign({ }, message.payload, { suspension }))
+
+ return super.render(new Proxy(message, {
+ get: function (target, property) {
+ if (property === 'payload') return payload
+ return target[property]
+ }
+ }))
+ }
+
+ shouldUpdateUserList(message) {
+ return true
+ }
+ }
+
+ return Suspend
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Team.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Team.js
new file mode 100644
index 0000000..35033a5
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Team.js
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Plain' ], function (Plain) {
+ "use strict";
+
+ class Team extends Plain {
+ joinable(a, b) {
+ return a.userID === b.userID
+ }
+
+ renderPlainText(message) {
+ return `[⭐] ${message.payload.plaintextMessage}`
+ }
+ }
+
+ return Team
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/TemproomCreated.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/TemproomCreated.js
new file mode 100644
index 0000000..c1355d9
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/TemproomCreated.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../MessageType' ], function (MessageType) {
+ "use strict";
+
+ class TemproomCreated extends MessageType {
+
+ }
+
+ return TemproomCreated
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/TemproomInvited.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/TemproomInvited.js
new file mode 100644
index 0000000..09cf9f7
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/TemproomInvited.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../MessageType' ], function (MessageType) {
+ "use strict";
+
+ class TemproomInvited extends MessageType {
+
+ }
+
+ return TemproomInvited
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Tombstone.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Tombstone.js
new file mode 100644
index 0000000..4aa6d9f
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Tombstone.js
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../MessageType' ], function (MessageType) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'UiMessageStream' ].concat(MessageType.DEPENDENCIES || [ ])
+ class Tombstone extends MessageType {
+ constructor(messageStream, ...superDeps) {
+ super(...superDeps)
+
+ this.messageStream = messageStream
+ }
+
+ render(message) {
+ if (message.isDeleted) {
+ return super.render(message)
+ }
+
+ const messageID = message.payload.messageID
+ const node = elById(`message-${messageID}`)
+ if (!node) return false
+
+ node.classList.add('tombstone')
+
+ const chatMessage = node.querySelector('.chatMessage')
+ if (!chatMessage) return false
+
+ const rendered = super.render(message)
+ const oldIcon = node.querySelector('.chatMessageContent > .chatMessageIcon')
+ const newIcon = rendered.querySelector('.chatMessageIcon')
+
+ if (oldIcon) {
+ oldIcon.parentNode.replaceChild(newIcon, oldIcon)
+ }
+ else {
+ chatMessage.parentNode.insertBefore(newIcon, chatMessage)
+ }
+
+ chatMessage.parentNode.replaceChild(rendered.querySelector('.chatMessage'), chatMessage)
+
+ return false
+ }
+ }
+ Tombstone.DEPENDENCIES = DEPENDENCIES
+
+ return Tombstone
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Unsuspend.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Unsuspend.js
new file mode 100644
index 0000000..5784da2
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Unsuspend.js
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../MessageType' ], function (MessageType) {
+ "use strict";
+
+ class Unsuspend extends MessageType {
+ shouldUpdateUserList(message) {
+ return true
+ }
+ }
+
+ return Unsuspend
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Where.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Where.js
new file mode 100644
index 0000000..feb35df
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Where.js
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Dom/Traverse'
+ , 'WoltLabSuite/Core/Language'
+ , '../MessageType'
+ ], function (DomTraverse, Language, MessageType) {
+ "use strict";
+
+ class Where extends MessageType {
+ render(message) {
+ const fragment = super.render(message)
+
+ const icon = elCreate('span')
+ icon.classList.add('icon', 'icon16', 'fa-times', 'jsTooltip', 'hideIcon')
+ icon.setAttribute('title', Language.get('wcf.global.button.hide'))
+ icon.addEventListener('click', () => elHide(DomTraverse.parentBySel(icon, '.chatMessageBoundary')))
+
+ const elem = fragment.querySelector('.jsRoomInfo > .containerHeadline')
+ elem.insertBefore(icon, elem.firstChild)
+
+ return fragment
+ }
+ }
+
+ return Where
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/MessageType/Whisper.js b/files_wcf/js/Bastelstu.be/Chat/MessageType/Whisper.js
new file mode 100644
index 0000000..626bd31
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/MessageType/Whisper.js
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Plain' ], function (Plain) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'UiInput' ].concat(Plain.DEPENDENCIES || [ ])
+ class Whisper extends Plain {
+ constructor(input, ...superDeps) {
+ super(...superDeps)
+
+ this.input = input
+ }
+
+ render(message) {
+ const fragment = super.render(message)
+
+ if (this.input != null) {
+ Array.prototype.forEach.call(fragment.querySelectorAll('[data-insert-whisper]'), (function (el) {
+ el.addEventListener('click', (function () {
+ const username = el.dataset.insertWhisper
+ const sanitizedUsername = username.replace(/"/g, '""')
+ const command = `/whisper "${sanitizedUsername}"`
+
+ if (this.input.getText().indexOf(command) !== 0) {
+ this.input.insertText(`${command} `, { prepend: true, append: false })
+ this.input.focus()
+ }
+ }).bind(this))
+ }).bind(this))
+ }
+
+ return fragment
+ }
+
+ joinable(a, b) {
+ return a.userID === b.userID && a.payload.recipient === b.payload.recipient
+ }
+ }
+ Whisper.DEPENDENCIES = DEPENDENCIES
+
+ return Whisper
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Messenger.js b/files_wcf/js/Bastelstu.be/Chat/Messenger.js
new file mode 100644
index 0000000..90083bf
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Messenger.js
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './console'
+ , 'Bastelstu.be/PromiseWrap/Ajax'
+ , './Room'
+ ], function (console, Ajax, Room) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'sessionID', 'Room', 'Message' ]
+ class Messenger {
+ constructor(sessionID, room, Message) {
+ if (!(room instanceof Room)) throw new TypeError('You must pass a Room to the Messenger')
+
+ this.sessionID = sessionID
+ this.room = room
+ this.Message = Message
+ }
+
+ async pull(from = 0, to = 0, inLog = false) {
+ console.debug(`Messenger.pull`, 'from', from, 'to', to, 'inLog', inLog)
+
+ const payload = { actionName: 'pull'
+ , parameters: { inLog }
+ }
+ if (from !== 0 && to !== 0) {
+ throw new Error('You must not set both from and to')
+ }
+ if (from !== 0) payload.parameters.from = from
+ if (to !== 0) payload.parameters.to = to
+
+ const data = await Ajax.api(this, payload)
+ const messages = Object.values(data.returnValues.messages).map((item) => this.Message.instance(item))
+ const { from: newFrom, to: newTo } = data.returnValues
+
+ return { messages, from: newFrom, to: newTo }
+ }
+
+ async push({ commandID, parameters }) {
+ const payload = { actionName: 'push'
+ , parameters: { commandID
+ , parameters: JSON.stringify(parameters)
+ }
+ }
+
+ return Ajax.api(this, payload)
+ }
+
+ _ajaxSetup() {
+ return { silent: true
+ , ignoreError: true
+ , data: { className: 'chat\\data\\message\\MessageAction'
+ , parameters: { roomID: this.room.roomID
+ , sessionID: this.sessionID
+ }
+ }
+ }
+ }
+ }
+ Messenger.DEPENDENCIES = DEPENDENCIES
+
+ return Messenger
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/ParseError.js b/files_wcf/js/Bastelstu.be/Chat/ParseError.js
new file mode 100644
index 0000000..a9e2179
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/ParseError.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ ], function () {
+ "use strict";
+
+ class ParseError extends Error {
+ constructor(message, data) {
+ super(message)
+
+ this.data = data
+ }
+ }
+
+ return ParseError
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Parser.js b/files_wcf/js/Bastelstu.be/Chat/Parser.js
new file mode 100644
index 0000000..fc0de16
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Parser.js
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'Bastelstu.be/parser-combinator'
+ ], function (parsec) {
+ "use strict";
+
+ const { C, F, N, X, parser, Streams } = parsec
+ const response = parsec.parsec.response
+
+ const peek = function (p) {
+ return new parser((input, index = 0) =>
+ p
+ .parse(input, index)
+ .fold(
+ accept => response.accept(accept.value, accept.input, index, false),
+ reject => response.reject(input.location(reject.offset), false)
+ )
+ );
+ }
+
+ const Whitespace = F.satisfy(item => /\s/.test(item))
+
+ const Rest = F.any.optrep().map(item => item.join(''))
+ const Rest1 = F.any.rep().map(item => item.join(''))
+
+ const AlnumTrigger = C.letter.or(N.digit).rep().map(item => item.join(''))
+ const SymbolicTrigger = F.not(C.letter.or(N.digit).or(Whitespace)).rep().map(item => item.join(''))
+ const Slash = C.char('/')
+ const Trigger = Slash.thenRight(
+ peek(Slash.map(item => null)).or(AlnumTrigger.thenLeft(Whitespace.rep().or(F.eos))).or(SymbolicTrigger.thenLeft(Whitespace.optrep()))
+ ).or(F.returns(null))
+ const Command = Trigger.then(Rest)
+
+ const Quote = C.char('"')
+ const QuotedUsername = Quote.thenRight(
+ ((Quote.thenRight(Quote)).or(F.not(Quote))).rep()
+ ).thenLeft(Quote).map(item => item.join(''))
+ const Comma = C.char(',')
+ const UnquotedUsername = F.not(Comma.or(Quote).or(Whitespace)).then(F.not(Comma.or(Whitespace)).optrep().map(item => item.join(''))).map(item => item.join(''))
+ const Username = QuotedUsername.or(UnquotedUsername)
+
+ const Decimal = (length) => N.digit.occurrence(length).map(item => parseInt(item.join(''), 10))
+
+ const Hexadecimal = N.digit
+ .or(C.charIn('abcdefABCDEF'))
+ .rep()
+ .map(x => x.join(''))
+
+ const RGBHex = (C.char('#').opt())
+ .thenRight(
+ Hexadecimal.filter(x => x.length === 3 || x.length === 6)
+ .map(item => {
+ if (item.length === 3) {
+ item = `${item[0]}${item[0]}${item[1]}${item[1]}${item[2]}${item[2]}`
+ }
+
+ return item
+ })
+ ).map(item => `#${item}`)
+
+ const Dash = C.char('-')
+ const Datestring = Decimal(4).filter(item => 2000 <= item && item <= 2030)
+ .thenLeft(Dash).then(Decimal(2).filter(item => 1 <= item && item <= 12))
+ .thenLeft(Dash).then(Decimal(2).filter(item => 1 <= item))
+
+ const Colon = C.char(':')
+ const Timestring = Decimal(2).filter(item => 0 <= item && item <= 23)
+ .thenLeft(Colon).then(Decimal(2).filter(item => 0 <= item && item <= 59))
+ .thenLeft(Colon).then(Decimal(2).filter(item => 0 <= item && item <= 59))
+
+ const ISODate = Datestring.then(C.char('T').thenRight(Timestring).opt()).map(function ([ year, month, day, time ]) {
+ const date = new Date()
+ date.setFullYear(year)
+ date.setMonth(month - 1)
+ date.setDate(day)
+
+ time.map(function ([ hour, minute, second ]) {
+ date.setHours(hour)
+ date.setMinutes(minute)
+ date.setSeconds(second)
+ })
+
+ return date
+ })
+
+ return {
+ Streams,
+ stream: Streams,
+ AlnumTrigger,
+ Colon,
+ Command,
+ Dash,
+ Datestring,
+ Decimal,
+ Hexadecimal,
+ ISODate,
+ Quote,
+ QuotedUsername,
+ RGBHex,
+ Rest,
+ Rest1,
+ Slash,
+ SymbolicTrigger,
+ Timestring,
+ Trigger,
+ UnquotedUsername,
+ Username,
+ Whitespace,
+ C,
+ F,
+ N,
+ X,
+ }
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/ProfileStore.js b/files_wcf/js/Bastelstu.be/Chat/ProfileStore.js
new file mode 100644
index 0000000..969bf83
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/ProfileStore.js
@@ -0,0 +1,195 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'Bastelstu.be/PromiseWrap/Ajax'
+ , './DataStructure/LRU'
+ , './User'
+ , 'WoltLabSuite/Core/User'
+ ], function (Ajax, LRU, User, CoreUser) {
+ "use strict";
+
+ const DEPENDENCIES = [ ]
+ /**
+ * ProfileStore stores information about users.
+ */
+ class ProfileStore {
+ constructor() {
+ this.users = new Map()
+ this.processing = new Map()
+
+ this.lastActivity = new LRU()
+ }
+
+ /**
+ * Ensures that information about the given userIDs are available
+ * in the store. The returned promise resolves once all the requests
+ * to fetch the data finished successfully.
+ *
+ * @param {number[]} userIDs
+ * @returns {Promise}
+ */
+ async ensureUsersByIDs(userIDs) {
+ // Dedup
+ userIDs = userIDs.filter((value, index, self) => self.indexOf(value) === index)
+ .map(userID => parseInt(userID, 10))
+
+ const missing = [ ]
+ const promises = [ ]
+ userIDs.forEach((function (userID) {
+ if (this.isRecent(userID)) return
+ if (this.processing.has(userID)) {
+ promises.push(this.processing.get(userID))
+ return
+ }
+ missing.push(userID)
+ }).bind(this))
+
+ if (missing.length > 0) {
+ const payload = { actionName: 'getUsersByID'
+ , parameters: { userIDs: missing }
+ }
+ const request = (async _ => {
+ try {
+ const response = await Ajax.api(this, payload)
+ return Object.entries(response.returnValues).forEach(([ userID, user ]) => {
+ userID = parseInt(userID, 10)
+ const data = { user: new User(user)
+ , date: Date.now()
+ }
+ this.users.set(userID, data)
+ this.processing.delete(userID)
+ })
+ }
+ catch (err) {
+ missing.forEach(userID => this.processing.delete(userID))
+
+ throw err
+ }
+ })()
+
+ missing.forEach(userID => this.processing.set(userID, request))
+ promises.push(request)
+ }
+
+ await Promise.all(promises)
+ }
+
+ /**
+ * Returns information about the given userIDs.
+ *
+ * @param {number[]} userIDs
+ * @returns {Promise}
+ */
+ async getUsersByIDs(userIDs) {
+ await this.ensureUsersByIDs(userIDs)
+
+ return new Map(userIDs.map(userID => [ userID, this.get(userID) ]))
+ }
+
+ /**
+ * Returns information about the currently logged in user.
+ *
+ * @returns {Promise}
+ */
+ getSelf() {
+ const self = this.get(CoreUser.userId)
+ if (self == null) {
+ throw new Error('Unreachable')
+ }
+
+ return self
+ }
+
+ /**
+ * Returns information about the given userID.
+ *
+ * @param {number} userID
+ * @returns {?User} null if no information are known
+ */
+ get(userID) {
+ const user = this.users.get(userID)
+
+ if (user != null) {
+ return user.user
+ }
+
+ return user
+ }
+
+ /**
+ * Returns whether information about the given userID are known.
+ *
+ * @param {number} userID
+ * @returns {boolean}
+ */
+ has(userID) {
+ return this.users.has(userID)
+ }
+
+ /**
+ * Forces an update of the information about the given userID.
+ *
+ * @param {number} userID
+ */
+ expire(userID) {
+ if (!this.users.has(userID)) return
+
+ this.users.get(userID).date = 0
+ }
+
+ /**
+ * Returns whether the information about the given userID are recent.
+ *
+ * @param {number} userID
+ * @returns {boolean}
+ */
+ isRecent(userID) {
+ const user = this.users.get(userID)
+
+ if (user != null) {
+ return user.date > (Date.now() - (5 * 60e3))
+ }
+
+ return false
+ }
+
+ /**
+ * Returns the stored information.
+ *
+ * @returns {User[]}
+ */
+ values() {
+ return Array.from(this.users.values()).map(item => item.user)
+ }
+
+ pushLastActivity(userID) {
+ if (!userID) return
+
+ this.lastActivity.add(userID)
+ }
+
+ * getLastActivity() {
+ yield * this.lastActivity
+ }
+
+ _ajaxSetup() {
+ return { silent: true
+ , ignoreError: true
+ , data: { className: 'chat\\data\\user\\UserAction' }
+ }
+ }
+ }
+ ProfileStore.DEPENDENCIES = DEPENDENCIES
+
+ return ProfileStore
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Room.js b/files_wcf/js/Bastelstu.be/Chat/Room.js
new file mode 100644
index 0000000..3d8a12a
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Room.js
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'Bastelstu.be/PromiseWrap/Ajax'
+ , 'WoltLabSuite/Core/Core'
+ , './User'
+ ], function (Ajax, Core, User) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'sessionID', 'roomID' ]
+ /**
+ * Represents a chat room.
+ */
+ class Room {
+ constructor(sessionID, roomID) {
+ this.sessionID = sessionID
+ this.roomID = roomID
+ }
+
+ /**
+ * Sends a request to join the room.
+ *
+ * @returns {Promise}
+ */
+ async join() {
+ const payload = { className: 'chat\\data\\room\\RoomAction'
+ , actionName: 'join'
+ , parameters: { roomID: this.roomID
+ , sessionID: this.sessionID
+ }
+ }
+
+ return Ajax.api(this, payload)
+ }
+
+ /**
+ * Sends a request to leave the room.
+ *
+ * @param {boolean} unload Send a beacon if true'ish and a regular AJAX request otherwise.
+ */
+ leave(unload = false) {
+ const payload = { className: 'chat\\data\\room\\RoomAction'
+ , actionName: 'leave'
+ , parameters: { roomID: this.roomID
+ , sessionID: this.sessionID
+ }
+ }
+
+ if (unload && FormData && (navigator.sendBeacon || window.fetch)) {
+ // Ordinary AJAX requests are unreliable during unload:
+ // Use navigator.sendBeacon if available, otherwise hope
+ // for the best and clean up based on a time out.
+
+ const url = WSC_API_URL + 'index.php?ajax-proxy/&t=' + SECURITY_TOKEN
+
+ const formData = new FormData()
+ Core.serialize(payload)
+ .split('&')
+ .map((item) => item.split('='))
+ .map((item) => item.map(decodeURIComponent))
+ .forEach((item) => formData.append(item[0], item[1]))
+
+ if (navigator.sendBeacon) {
+ navigator.sendBeacon(url, formData)
+ }
+
+ if (window.fetch) {
+ fetch(url, { method: 'POST', keepalive: true, redirect: 'follow', body: formData })
+ }
+
+ return Promise.resolve()
+ }
+ else {
+ return Ajax.api(this, payload)
+ }
+ }
+
+ /**
+ * Sends a request to retrieve the userIDs inhabiting this room.
+ *
+ * @returns {Promise}
+ */
+ async getUsers() {
+ const payload = { className: 'chat\\data\\room\\RoomAction'
+ , actionName: 'getUsers'
+ , objectIDs: [ this.roomID ]
+ }
+
+ const result = await Ajax.api(this, payload)
+
+ return Object.values(result.returnValues).map(user => new User(user))
+ }
+
+ _ajaxSetup() {
+ return { silent: true
+ , ignoreError: true
+ }
+ }
+ }
+ Room.DEPENDENCIES = DEPENDENCIES
+
+ return Room
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Template.js b/files_wcf/js/Bastelstu.be/Chat/Template.js
new file mode 100644
index 0000000..4f1f7a1
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Template.js
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Template' ], function (_Template) {
+ "use strict";
+
+ /**
+ * Template extends WoltLab Suite's Templates by passing in a list of
+ * re-usable sub-templates.
+ */
+ class Template extends _Template {
+ constructor(string, templates = { }) {
+ super(string)
+
+ this.templates = templates
+
+ const oldFetch = this.fetch
+ this.fetch = (function (variables) {
+ variables = Object.assign({ }, variables)
+
+ const templates = Object.assign({ }, this.templates, variables.t || { })
+ variables.t = templates
+
+ return oldFetch(variables)
+ }).bind(this)
+ }
+ }
+
+ return Template
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui.js b/files_wcf/js/Bastelstu.be/Chat/Ui.js
new file mode 100644
index 0000000..4e7f45f
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui.js
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ ], function () {
+ "use strict";
+
+ class Ui {
+ constructor() {
+ }
+ }
+
+ return Ui
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/AutoAway.js b/files_wcf/js/Bastelstu.be/Chat/Ui/AutoAway.js
new file mode 100644
index 0000000..fff546e
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/AutoAway.js
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../console'
+ , '../CommandHandler'
+ , '../LocalStorage'
+ , '../Messenger'
+ , '../ProfileStore'
+ , 'WoltLabSuite/Core/Language'
+ , 'WoltLabSuite/Core/Timer/Repeating'
+ ], function (console, CommandHandler, LocalStorage, Messenger, ProfileStore, Language, RepeatingTimer) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'config', 'CommandHandler', 'Messenger', 'ProfileStore', 'UiInput' ]
+ class AutoAway {
+ constructor(config, commandHandler, messenger, profileStore, input) {
+ if (!(commandHandler instanceof CommandHandler)) throw new TypeError('You must pass a CommandHandler to the AutoAway')
+ if (!(messenger instanceof Messenger)) throw new TypeError('You must pass a Messenger to the AutoAway')
+ if (!(profileStore instanceof ProfileStore)) throw new TypeError('You must pass a ProfileStore to the AutoAway')
+
+ this.storage = new LocalStorage('AutoAway.')
+ this.awayCommand = commandHandler.getCommandByIdentifier('be.bastelstu.chat', 'away')
+ if (this.awayCommand == null) {
+ throw new Error('Unreachable')
+ }
+ this.config = config
+ this.messenger = messenger
+ this.input = input
+ this.profileStore = profileStore
+ }
+
+ bootstrap() {
+ if (this.config.autoAwayTime === 0) {
+ return
+ }
+ if (!this.awayCommand.isAvailable) {
+ return
+ }
+
+ this.timer = new RepeatingTimer(this.setAway.bind(this), this.config.autoAwayTime * 60e3)
+ this.input.on('input', this.inputListener = (event) => {
+ this.storage.set('channel', Date.now())
+ this.reset()
+ })
+ this.storage.observe('channel', this.reset.bind(this))
+ }
+
+ ingest(messages) {
+ if (messages.some(message => message.isOwnMessage())) this.reset()
+ }
+
+ reset() {
+ console.debug('AutoAway.reset', `Resetting timer`)
+
+ if (!this.timer) return
+
+ this.timer.setDelta(this.config.autoAwayTime * 60e3)
+ }
+
+ async setAway() {
+ console.debug('AutoAway.setAway', `Attempting to set as away`)
+
+ if (this.storage.get('setAway') >= (Date.now() - 10e3)) {
+ console.debug('AutoAway.setAway', `setAway called within the last 10 seconds in another Tab`)
+ return
+ }
+ this.storage.set('setAway', Date.now())
+
+ if (this.profileStore.getSelf().away) {
+ console.debug('AutoAway.setAway', `User is already away`)
+ return
+ }
+
+ this.messenger.push({ commandID: this.awayCommand.id, parameters: { reason: Language.get('chat.user.autoAway') } })
+ }
+ }
+ AutoAway.DEPENDENCIES = DEPENDENCIES
+
+ return AutoAway
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Chat.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Chat.js
new file mode 100644
index 0000000..3ec5161
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Chat.js
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Environment'
+ , '../Ui'
+ ], function (Environment, Ui) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'UiAutoAway'
+ , 'UiConnectionWarning'
+ , 'UiInput'
+ , 'UiInputAutocompleter'
+ , 'UiMessageStream'
+ , 'UiMessageActionDelete'
+ , 'UiMobile'
+ , 'UiNotification'
+ , 'UiReadMarker'
+ , 'UiSettings'
+ , 'UiTopic'
+ , 'UiUserActionDropdownHandler'
+ , 'UiUserList'
+ ]
+ class Chat extends Ui {
+ constructor(autoAway, connectionWarning, input, autocompleter, messageStream, messageActionDelete, mobile, notification, readMarker, settings, topic, userActionDropdownHandler, userList) {
+ super()
+
+ this.actionDropdownHandler = userActionDropdownHandler
+ this.autoAway = autoAway
+ this.autocompleter = autocompleter
+ this.connectionWarning = connectionWarning
+ this.input = input
+ this.messageStream = messageStream
+ this.messageActionDelete = messageActionDelete
+ this.mobile = mobile
+ this.notification = notification
+ this.readMarker = readMarker
+ this.settings = settings
+ this.topic = topic
+ this.userList = userList
+ }
+
+ bootstrap() {
+ this.actionDropdownHandler.bootstrap()
+ this.autoAway.bootstrap()
+ this.autocompleter.bootstrap()
+ this.connectionWarning.bootstrap()
+ this.input.bootstrap()
+ this.messageStream.bootstrap()
+ this.messageActionDelete.bootstrap()
+ this.mobile.bootstrap()
+ this.notification.bootstrap()
+ this.readMarker.bootstrap()
+ this.settings.bootstrap()
+ this.topic.bootstrap()
+ this.userList.bootstrap()
+ }
+ }
+ Chat.DEPENDENCIES = DEPENDENCIES
+
+ return Chat
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/ConnectionWarning.js b/files_wcf/js/Bastelstu.be/Chat/Ui/ConnectionWarning.js
new file mode 100644
index 0000000..142c59c
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/ConnectionWarning.js
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../console'
+ ], function (console) {
+ "use strict";
+
+ class ConnectionWarning {
+ constructor() {
+ this.warning = elById('chatConnectionWarning')
+ }
+
+ bootstrap() {
+
+ }
+
+ show() {
+ elShow(this.warning)
+ if (this.timeout) return
+
+ console.debug('ConnectionWarning.show', 'Setting timeout')
+ this.timeout = setTimeout(_ => {
+ console.debug('ConnectionWarning.show', 'Timeout has passed')
+ this.timeout = undefined
+
+ if (this.autoHide) {
+ console.debug('ConnectionWarning.show', 'Hiding connection warning')
+ this.hide()
+ }
+ }, 10e3)
+ }
+
+ hide(force = false) {
+ if (!this.timeout || force) {
+ elHide(this.warning)
+ window.clearTimeout(this.timeout)
+ }
+ else {
+ console.debug('ConnectionWarning.hide', 'Automatically hiding after timeout has passed')
+ this.autoHide = true
+ }
+ }
+
+ toggle() {
+ elToggle(this.warning)
+ }
+ }
+
+ return ConnectionWarning
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/ErrorDialog.js b/files_wcf/js/Bastelstu.be/Chat/Ui/ErrorDialog.js
new file mode 100644
index 0000000..10965e2
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/ErrorDialog.js
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Language'
+ , 'WoltLabSuite/Core/Template'
+ , 'WoltLabSuite/Core/Ui/Dialog'
+ ], function (Language, Template, UiDialog) {
+ "use strict";
+
+ const html = [ '[type="x-text/template"]'
+ , '[data-application="be.bastelstu.chat"]'
+ , '[data-template-name="be-bastelstu-chat-errorDialog"]'
+ ].join('')
+
+ const wrapper = new Template(elBySel(html).textContent)
+
+ class ErrorDialog {
+ constructor(message) {
+ const options = { title: Language.get('wcf.global.error.title')
+ , closable: false
+ }
+
+ UiDialog.openStatic('chatError', wrapper.fetch({ message }), options)
+ }
+ }
+
+ return ErrorDialog
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Input.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Input.js
new file mode 100644
index 0000000..dfe4f2c
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Input.js
@@ -0,0 +1,139 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../console'
+ , '../Helper'
+ , 'WoltLabSuite/Core/Core'
+ , 'WoltLabSuite/Core/Event/Key'
+ , '../DataStructure/EventEmitter'
+ , '../DataStructure/Throttle'
+ ], function (console, Helper, Core, EventKey, EventEmitter, Throttle) {
+ "use strict";
+
+ class Input {
+ constructor() {
+ this.inputContainer = elById('chatInputContainer')
+ this.input = elBySel('textarea', this.inputContainer)
+ this.charCounter = elBySel('.charCounter', this.inputContainer)
+ this.errorElement = elBySel('.innerError', this.inputContainer)
+ }
+
+ bootstrap() {
+ if (typeof window.elInnerError === 'function') {
+ elRemove(this.errorElement)
+ }
+
+ this.input.addEventListener('keydown', this.handleInputKeyDown.bind(this))
+ this.input.addEventListener('input', Throttle(this.handleInput.bind(this)))
+
+ Helper.makeFlexible(this.input)
+ this.handleInput()
+ }
+
+ handleInput(event) {
+ this.charCounter.textContent = `${this.input.value.length} / ${this.input.getAttribute('maxlength')}`
+ this.emit('input')
+ }
+
+ handleInputKeyDown(event) {
+ if (EventKey.Enter(event) && !event.shiftKey) {
+ // prevent generation of a new line
+ event.preventDefault()
+
+ if (this.getText().length === 0) return
+
+ const parameters = { cancel: false, input: this }
+ this.emit('beforeSubmit', parameters)
+ if (!parameters.cancel) {
+ this.emit('submit')
+ }
+ }
+ else if (EventKey.Tab(event)) {
+ // prevent leaving the input
+ event.preventDefault()
+
+ this.emit('autocomplete')
+ }
+ }
+
+ getText(raw = false) {
+ if (raw) {
+ return this.input.value
+ }
+
+ return this.input.value.trim()
+ }
+
+ select(start, end = undefined) {
+ if (end === undefined) end = this.getText(true).length
+
+ this.input.setSelectionRange(start, end)
+ }
+
+ focus() {
+ this.input.focus()
+ }
+
+ insertText(text, options) {
+ this.focus()
+
+ options = Object.assign({ append: true
+ , prepend: false
+ }, options)
+
+ if (!(options.append || options.prepend)) {
+ // replace
+ this.input.value = text
+ }
+
+ if (options.append) {
+ this.input.value += text;
+ }
+
+ if (options.prepend) {
+ this.input.value = text + this.input.value;
+ }
+
+ // always position caret at the end
+ const length = this.input.value.length
+ this.input.setSelectionRange(length, length)
+
+ Core.triggerEvent(this.input, 'input')
+ }
+
+ inputError(message) {
+ if (typeof window.elInnerError === 'function') {
+ elInnerError(this.inputContainer.firstElementChild, message)
+ }
+ else {
+ this.inputContainer.classList.add('formError')
+ this.errorElement.textContent = message
+ elShow(this.errorElement)
+ }
+ }
+
+ hideInputError() {
+ if (typeof window.elInnerError === 'function') {
+ elInnerError(this.inputContainer.firstElementChild, false)
+ }
+ else {
+ this.inputContainer.classList.remove('formError')
+ this.errorElement.textContent = ''
+ elHide(this.errorElement)
+ }
+ }
+ }
+ EventEmitter(Input.prototype)
+
+ return Input
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Input/Autocompleter.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Input/Autocompleter.js
new file mode 100644
index 0000000..436aae7
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Input/Autocompleter.js
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Dom/Util'
+ , 'WoltLabSuite/Core/Event/Key'
+ , 'WoltLabSuite/Core/Ui/Suggestion'
+ ], function (DomUtil, EventKey, Suggestion) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'UiInput' ]
+ class Autocompleter extends Suggestion {
+ constructor(input) {
+ const elementId = DomUtil.identify(input.input)
+ const options = { callbackSelect: (() => null) }
+
+ super(elementId, options)
+
+ this.input = input
+ this._options.callbackSelect = this.callbackSelect.bind(this)
+ }
+
+ bootstrap() {
+ this.input.on('beforeSubmit', (event) => {
+ if (event.target !== this.input) return
+
+ if (this.isActive() || this.cancelNextSubmit) {
+ event.detail.cancel = true
+ }
+ this.cancelNextSubmit = false
+ })
+ }
+
+ _keyDown(event) {
+ const result = super._keyDown(event)
+
+ if (!result && EventKey.Enter(event)) {
+ this.cancelNextSubmit = true
+ }
+ }
+
+ _keyUp(event) {
+ const value = this.input.getText(true)
+
+ if (this._value !== value) {
+ this._ajaxSuccess({ returnValues: [] })
+ this._value = value
+ }
+ }
+
+ callbackSelect(_, selected) {
+ this.input.insertText(selected.objectId, { append: false })
+ }
+
+ _ajaxSuccess(...args) {
+ this._value = this.input.getText(true)
+ return super._ajaxSuccess(...args)
+ }
+ }
+ Autocompleter.DEPENDENCIES = DEPENDENCIES
+
+ return Autocompleter
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Log.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Log.js
new file mode 100644
index 0000000..64e016d
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Log.js
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../Ui' ], function (Ui) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'UiMessageStream', 'UiMessageActionDelete' ]
+ class Log extends Ui {
+ constructor(messageStream, messageActionDelete) {
+ super()
+
+ this.messageStream = messageStream
+ this.messageActionDelete = messageActionDelete
+ }
+
+ bootstrap() {
+ this.messageStream.bootstrap()
+ this.messageStream.enableAutoscroll = false
+ this.messageActionDelete.bootstrap()
+ }
+ }
+ Log.DEPENDENCIES = DEPENDENCIES
+
+ return Log
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/MessageActions/Delete.js b/files_wcf/js/Bastelstu.be/Chat/Ui/MessageActions/Delete.js
new file mode 100644
index 0000000..b0f88e5
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/MessageActions/Delete.js
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'Bastelstu.be/Chat/Message', 'Bastelstu.be/PromiseWrap/Ajax', 'Bastelstu.be/PromiseWrap/Ui/Confirmation' ], function (Message, Ajax, Confirmation) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'UiMessageStream', 'Message' ]
+ class Delete {
+ constructor(messageStream, message) {
+ this.messageStream = messageStream
+ this.Message = message
+ }
+
+ bootstrap() {
+ this.messageStream.on('ingested', this.bindListener.bind(this))
+ }
+
+ bindListener({ detail }) {
+ detail.forEach(item => {
+ if (!item) return
+
+ const { node, message } = item
+ const button = node.querySelector('.jsDeleteButton')
+ if (!button) return
+
+ button.addEventListener('click', async event => {
+ event.preventDefault()
+
+ await Confirmation.show({
+ message: button.dataset.confirmMessageHtml,
+ messageIsHtml: true
+ })
+
+ await this.delete(message.messageID)
+
+ elRemove(button.closest('li'))
+ })
+ })
+ }
+
+ async delete(messageID) {
+ {
+ const payload = { objectIDs: [ messageID ] }
+
+ await Ajax.api(this, payload)
+ }
+
+ {
+ const objectType = 'be.bastelstu.chat.messageType.tombstone'
+ const payload = { messageID
+ , userID: null
+ }
+ const message = this.Message.instance({ objectType, payload })
+ message.getMessageType().render(message)
+ }
+ }
+
+ _ajaxSetup() {
+ return { silent: true
+ , ignoreError: true
+ , data: { className: 'chat\\data\\message\\MessageAction'
+ , actionName: 'trash'
+ }
+ }
+ }
+ }
+ Delete.DEPENDENCIES = DEPENDENCIES
+
+ return Delete
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/MessageStream.js b/files_wcf/js/Bastelstu.be/Chat/Ui/MessageStream.js
new file mode 100644
index 0000000..7f95ae5
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/MessageStream.js
@@ -0,0 +1,343 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../Helper'
+ , 'WoltLabSuite/Core/Date/Util'
+ , 'WoltLabSuite/Core/Dom/Change/Listener'
+ , 'WoltLabSuite/Core/User'
+ , 'WoltLabSuite/Core/Dom/Traverse'
+ , '../DataStructure/EventEmitter'
+ , '../DataStructure/RedBlackTree/Tree'
+ ], function (Helper, DateUtil, DomChangeListener, User, DOMTraverse, EventEmitter, Tree) {
+ "use strict";
+
+ const enableAutoscroll = Symbol('enableAutoscroll')
+
+ const DEPENDENCIES = [ ]
+ class MessageStream {
+ constructor() {
+ this.stream = elById('chatMessageStream')
+ this.scrollContainer = elBySel('.scrollContainer', this.stream)
+
+ this[enableAutoscroll] = true
+ this.lastScrollPosition = undefined
+ this.nodeMap = new WeakMap()
+ this.positions = new Tree()
+ }
+
+ get enableAutoscroll() {
+ return this[enableAutoscroll]
+ }
+
+ set enableAutoscroll(value) {
+ this[enableAutoscroll] = value
+
+ if (this[enableAutoscroll]) {
+ this.scrollToBottom()
+ }
+ }
+
+ bootstrap() {
+ this.scrollContainer.addEventListener('copy', this.onCopy.bind(this))
+ this.scrollContainer.addEventListener('scroll', Helper.throttle(this.onScroll, 100, this), { passive: true })
+ }
+
+ getDateMarker(date) {
+ const dateMarker = elCreate('li')
+ dateMarker.classList.add('dateMarker')
+ const time = elCreate('time')
+ time.innerText = DateUtil.formatDate(date)
+ time.setAttribute('datetime', DateUtil.format(date, 'Y-m-d'))
+ dateMarker.appendChild(time)
+
+ return dateMarker
+ }
+
+ onDifferentDays(a, b) {
+ return DateUtil.format(a, 'Y-m-d') !== DateUtil.format(b, 'Y-m-d')
+ }
+
+ ingest(messages) {
+ let scrollTopBefore = this.enableAutoscroll ? 0 : this.scrollContainer.scrollTop
+ let prependedHeight = 0
+
+ const ul = elBySel('ul', this.scrollContainer)
+ const first = ul.firstElementChild
+
+ const ingested = messages.map((function (item) {
+ let currentScrollHeight = 0
+
+ const li = elCreate('li')
+
+ // Allow messages types to not render a messages
+ // This can be used for status messages like ChatUpdate
+ let fragment
+ if ((fragment = item.getMessageType().render(item)) === false) return
+
+ if (fragment.querySelector(`.userMention[data-user-id="${User.userId}"]`)) li.classList.add('mentioned')
+
+ li.appendChild(fragment)
+
+ li.classList.add('chatMessageBoundary')
+ li.setAttribute('id', `message-${item.messageID}`)
+ li.dataset.objectType = item.objectType
+ li.dataset.userId = item.userID
+ if (item.isOwnMessage()) li.classList.add('own')
+ if (item.isDeleted) li.classList.add('tombstone')
+
+ const position = this.positions.insert(item.messageID)
+ if (position[1] !== undefined) {
+ const sibling = elById(`message-${position[1]}`)
+ if (!sibling) throw new Error('Unreachable')
+
+ let nodeBefore, nodeAfter
+ let dateMarkerBetween = false
+ if (position[0] === 'LEFT') {
+ nodeAfter = sibling
+ nodeBefore = sibling.previousElementSibling
+
+ if (nodeBefore && nodeBefore.classList.contains('dateMarker')) {
+ elRemove(nodeBefore)
+ nodeBefore = sibling.previousElementSibling
+ }
+ }
+ else if (position[0] === 'RIGHT') {
+ nodeBefore = sibling
+ nodeAfter = sibling.nextElementSibling
+
+ if (nodeAfter && nodeAfter.classList.contains('dateMarker')) {
+ elRemove(nodeAfter)
+ nodeAfter = sibling.nextElementSibling
+ }
+ }
+ else {
+ throw new Error('Unreachable')
+ }
+
+ const messageBefore = this.nodeMap.get(nodeBefore)
+ if (nodeBefore && !messageBefore) throw new Error('Unreachable')
+ const messageAfter = this.nodeMap.get(nodeAfter)
+ if (nodeAfter && !messageAfter) throw new Error('Unreachable')
+
+ if (!this.enableAutoscroll && nodeAfter) currentScrollHeight = this.scrollContainer.scrollHeight
+
+ let context = nodeAfter
+ if (nodeAfter) nodeAfter.classList.remove('first')
+ if (messageBefore) {
+ if (this.onDifferentDays(messageBefore.date, item.date)) {
+ const dateMarker = this.getDateMarker(item.date)
+ ul.insertBefore(dateMarker, nodeAfter)
+ li.classList.add('first')
+ }
+ else {
+ if (messageBefore.objectType !== item.objectType || !item.getMessageType().joinable(messageBefore, item)) {
+ li.classList.add('first')
+ }
+ }
+ }
+ else {
+ li.classList.add('first')
+ }
+ if (messageAfter) {
+ if (this.onDifferentDays(messageAfter.date, item.date)) {
+ const dateMarker = this.getDateMarker(messageAfter.date)
+ ul.insertBefore(dateMarker, nodeAfter)
+ context = dateMarker
+ nodeAfter.classList.add('first')
+ }
+ else {
+ if (messageAfter.objectType !== item.objectType || !item.getMessageType().joinable(item, messageAfter)) {
+ nodeAfter.classList.add('first')
+ }
+ }
+ }
+
+ ul.insertBefore(li, context);
+
+ if (!this.enableAutoscroll && nodeAfter) {
+ prependedHeight += this.scrollContainer.scrollHeight - currentScrollHeight
+ }
+ }
+ else {
+ li.classList.add('first')
+ ul.insertBefore(li, null)
+ }
+
+ this.nodeMap.set(li, item)
+
+ return { node: li
+ , message: item
+ }
+ }).bind(this));
+
+ if (ingested.some(item => item != null)) {
+ if (this.enableAutoscroll) {
+ this.scrollToBottom()
+ }
+ else {
+ this.stream.classList.add('activity')
+ this.scrollContainer.scrollTop = scrollTopBefore + prependedHeight
+ }
+ }
+
+ DomChangeListener.trigger()
+
+ this.emit('ingested', ingested)
+ }
+
+ scrollToBottom() {
+ this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight
+ this.stream.classList.remove('activity')
+ }
+
+ onScroll() {
+ const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer
+ const distanceFromTop = scrollTop
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight
+
+ let direction = 'down'
+
+ if (this.lastScrollPosition != null && scrollTop < this.lastScrollPosition) {
+ direction = 'up'
+ }
+
+ if (direction === 'up') {
+ if (distanceFromBottom > 7) {
+ this.emit('scrollUp')
+ }
+
+ if (distanceFromTop <= 7) {
+ this.emit('reachedTop')
+ }
+ else if (distanceFromTop <= 300) {
+ this.emit('nearTop')
+ }
+ }
+ else if (direction === 'down') {
+ if (distanceFromTop > 7) {
+ this.emit('scrollDown')
+ }
+
+ if (distanceFromBottom <= 7) {
+ this.scrollToBottom()
+ this.emit('reachedBottom')
+ }
+ else if (distanceFromBottom <= 300) {
+ this.emit('nearBottom')
+ }
+ }
+
+ this.lastScrollPosition = scrollTop
+ }
+
+ onCopy(event) {
+ const selection = window.getSelection()
+
+ // Similar to selecting nothing
+ if (selection.isCollapsed) return
+
+ // Get the first and last node in the selection
+ let originalStart, start, end, originalEnd
+ start = originalStart = selection.getRangeAt(0).startContainer
+ end = originalEnd = selection.getRangeAt(selection.rangeCount - 1).endContainer
+
+ const startOffset = selection.getRangeAt(0).startOffset
+ const endOffset = selection.getRangeAt(selection.rangeCount - 1).endOffset
+
+ // The Traverse module needs nodes of the Element type, the selected elements could be of type Text
+ while (!(start instanceof Element) && start.parentNode) start = start.parentNode
+ while (!(end instanceof Element) && end.parentNode) end = end.parentNode
+
+ if (!start || !end) throw new Error('Unexpected error, no element nodes in selection')
+
+ // Try to find the starting li element in the selection
+ if (!start.id || start.id.indexOf('message-') !== 0) {
+ start = DOMTraverse.parentBySel(start, "li[id^='message']", this.stream)
+ }
+
+ // Try to find the ending li element in the selection
+ if (!end.id || end.id.indexOf('message-') !== 0) {
+ end = DOMTraverse.parentBySel(end, "li[id^='message']", this.stream)
+ }
+
+ // Do not select a message if we selected only a new line
+ if (originalStart instanceof Text && originalStart.textContent.substring(startOffset) === "") {
+ start = DOMTraverse.next(start)
+ }
+
+ // The selection went outside of the stream container, end at the last li element
+ if (end === null) {
+ end = elBySel('ul > li:last-child', this.stream)
+ }
+
+ // Discard the selection, we selected only whitespace between two messages
+ if (start === end && endOffset === 0) return
+
+ // Do not include the ending message if there is no visible selection
+ if (start !== end && endOffset === 0) {
+ end = DOMTraverse.prev(end)
+ }
+
+ const elements = [ ]
+ let next = start
+
+ do {
+ elements.push(next)
+
+ if (next === end) break
+ }
+ while (next = DOMTraverse.next(next))
+
+ // Only apply our custom formatting when selecting multiple or whole messages
+ if (elements.length === 1) {
+ const range = document.createRange()
+ range.setStart(originalStart, startOffset)
+ range.setEnd(originalEnd, endOffset)
+
+ if (!Helper.rangeSpansTextContent(range, start.querySelector('.chatMessage'))) return
+ }
+
+ try {
+ event.clipboardData.setData('text/plain', elements.map((el, index, arr) => {
+ const message = this.nodeMap.get(el)
+
+ if (el.classList.contains('dateMarker')) return `== ${el.textContent.trim()} ==`
+
+ if (!message) return
+
+ const elem = elBySel('.chatMessage', el)
+
+ let body
+ if (typeof (body = message.getMessageType().renderPlainText(message)) === 'undefined' || body === false) {
+ body = Helper.getTextContent(elem).replace(/\t+/g, '\t') // collapse multiple tabs
+ .replace(/ +/g, ' ') // collapse multiple spaces
+ .replace(/([\t ]*\n){2,}/g, '\n') // collapse line consisting of tabs, spaces and newlines
+ .replace(/^[\t ]+|[\t ]+$/gm, '') // remove leading and trailing whitespace per line
+ }
+
+ return `[${message.formattedTime}] <${message.username}> ${body.trim()}`
+ }).filter(x => x).join('\n'))
+
+ event.preventDefault()
+ }
+ catch (e) {
+ console.error('Unable to use the clipboard API')
+ console.error(e)
+ }
+ }
+ }
+ EventEmitter(MessageStream.prototype)
+ MessageStream.DEPENDENCIES = DEPENDENCIES
+
+ return MessageStream
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Mobile.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Mobile.js
new file mode 100644
index 0000000..c89b546
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Mobile.js
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Ui/Screen' ], function (UiScreen) {
+ "use strict";
+
+ const initialized = Symbol('initialized')
+
+ class Mobile {
+ constructor() {
+ this[initialized] = false
+ }
+
+ bootstrap() {
+ UiScreen.on('screen-md-down', { match: this.enable.bind(this)
+ , unmatch: this.disable.bind(this)
+ , setup: this.init.bind(this)
+ })
+ }
+
+ init() {
+ if (this[initialized]) return
+
+ this[initialized] = true
+
+ this.initQuickSettings()
+ }
+
+ enable() {
+
+ }
+
+ disable() {
+
+ }
+
+ initQuickSettings() {
+ const navigation = elBySel('#chatQuickSettingsNavigation > ul')
+ const quickSettings = elById('chatQuickSettings')
+
+ navigation.addEventListener(WCF_CLICK_EVENT, event => {
+ event.stopPropagation()
+
+ // mimic dropdown behavior
+ window.setTimeout(() => {
+ navigation.classList.remove('open')
+ }, 10)
+ })
+
+ quickSettings.addEventListener(WCF_CLICK_EVENT, event => {
+ event.preventDefault()
+ event.stopPropagation()
+
+ navigation.classList.toggle('open')
+ })
+ }
+ }
+
+ return Mobile
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Notification.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Notification.js
new file mode 100644
index 0000000..c420c95
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Notification.js
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Language' ], function (Language) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'ProfileStore' ]
+ class Notification {
+ constructor(profileStore) {
+ this.profileStore = profileStore
+
+ this.unread = 0
+ this.active = true
+ this.browserTitle = document.title
+ this.systemEnabled = false
+ this.lastSeen = 0
+ }
+
+ bootstrap() {
+ document.addEventListener('visibilitychange', this.onVisibilitychange.bind(this))
+ }
+
+ get systemSupported() {
+ return "Notification" in window
+ }
+
+ get systemDenied() {
+ return window.Notification.permission === 'denied'
+ }
+
+ get systemGranted() {
+ if (this.systemDenied) {
+ console.warn('[Notification]', 'System Notifications: permission denied')
+ }
+
+ return window.Notification.permission === 'granted'
+ }
+
+ onVisibilitychange() {
+ this.active = !document.hidden
+
+ if (this.active) {
+ this.unread = 0
+ this.updateBrowserTitle()
+ }
+ }
+
+ ingest(messages) {
+ if (!this.active) {
+ messages.forEach(message => {
+ const body = message.getMessageType().renderPlainText(message)
+
+ if (body === false) return
+ if (message.messageID < this.lastSeen) return
+
+ this.lastSeen = message.messageID
+ this.unread++
+
+ if (this.systemEnabled && this.systemGranted) {
+ // The user information is guaranteed to be cached at this point
+ const user = this.profileStore.get(message.userID)
+ const title = Language.get('chat.notification.title', { message })
+ const options = { body
+ , icon: user.imageUrl
+ , badge: user.imageUrl
+ }
+
+ const notification = new window.Notification(title, options)
+
+ setTimeout(notification.close.bind(notification), 5e3)
+ }
+ })
+ }
+
+ this.updateBrowserTitle()
+ }
+
+ updateBrowserTitle() {
+ if (this.unread > 0) {
+ document.title = `(${this.unread}) ${this.browserTitle}`
+ }
+ else {
+ document.title = this.browserTitle
+ }
+ }
+
+ enableSystemNotifications() {
+ if (!this.systemSupported) return Promise.reject(new Error('Notifications are not supported'))
+
+ if (this.systemGranted) {
+ this.systemEnabled = true
+
+ return Promise.resolve()
+ }
+
+ return new Promise((resolve, reject) => {
+ window.Notification.requestPermission(permission => {
+ this.systemEnabled = permission === 'granted'
+
+ if (this.systemEnabled) {
+ resolve()
+ }
+ else {
+ reject(new Error(permission))
+ }
+ })
+ })
+ }
+
+ disableSystemNotifications() {
+ this.systemEnabled = false
+ }
+ }
+ Notification.DEPENDENCIES = DEPENDENCIES
+
+ return Notification
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/ReadMarker.js b/files_wcf/js/Bastelstu.be/Chat/Ui/ReadMarker.js
new file mode 100644
index 0000000..32da37f
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/ReadMarker.js
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ ], function () {
+ "use strict";
+
+ const DEPENDENCIES = [ 'UiMessageStream' ]
+ class ReadMarker {
+ constructor(messageStream) {
+ this.messageStream = messageStream
+ }
+
+ bootstrap() {
+ document.addEventListener('visibilitychange', this.onVisibilitychange.bind(this))
+ }
+
+ onVisibilitychange() {
+ if (document.hidden) {
+ const ul = elBySel('ul', this.messageStream.stream)
+ let lc = ul.lastElementChild
+
+ // delete previous markers
+ Array.prototype.forEach.call(document.querySelectorAll('.readMarker'), marker => {
+ marker.classList.remove('readMarker')
+ })
+
+ if (lc) {
+ lc.classList.add('readMarker')
+ }
+ }
+ }
+ }
+ ReadMarker.DEPENDENCIES = DEPENDENCIES
+
+ return ReadMarker
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Settings.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings.js
new file mode 100644
index 0000000..27bfcf6
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings.js
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ ], function () {
+ 'use strict';
+
+ const DEPENDENCIES = [ 'UiSettingsButton' ]
+ class Settings {
+ constructor(modules) {
+ this.modules = modules
+ this.buttons = Array.from(elBySelAll('#chatQuickSettingsNavigation .button[data-module]'))
+ }
+
+ bootstrap() {
+ this.buttons.forEach(element => {
+ this.modules[element.dataset.module.replace(/\./g, '-')].instance(element).bootstrap()
+ })
+ }
+ }
+ Settings.DEPENDENCIES = DEPENDENCIES
+
+ return Settings
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/AutoscrollButton.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/AutoscrollButton.js
new file mode 100644
index 0000000..cc2bb50
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/AutoscrollButton.js
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './ToggleButton' ], function (ToggleButton) {
+ 'use strict';
+
+ const DEPENDENCIES = [ 'UiMessageStream' ].concat(ToggleButton.DEPENDENCIES || [ ])
+ class AutoscrollButton extends ToggleButton {
+ constructor(element, messageStream, ...superDeps) {
+ super(element, true, undefined, ...superDeps)
+
+ this.messageStream = messageStream
+
+ this.messageStream.on('reachedBottom', this.enable.bind(this))
+ this.messageStream.on('scrollUp', this.disable.bind(this))
+ }
+
+ enable() {
+ super.enable()
+
+ this.messageStream.enableAutoscroll = true
+ }
+
+ disable() {
+ super.disable()
+
+ this.messageStream.enableAutoscroll = false
+ }
+ }
+ AutoscrollButton.DEPENDENCIES = DEPENDENCIES
+
+ return AutoscrollButton
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/Button.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/Button.js
new file mode 100644
index 0000000..d6ff216
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/Button.js
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ ], function () {
+ 'use strict';
+
+ const DEPENDENCIES = [ ]
+ class Button {
+ constructor(element) {
+ if (!element || !element instanceof Element) throw new Error('No DOM element provided')
+
+ this.element = element
+ }
+
+ bootstrap() {
+ this.element.addEventListener('click', this.onClick.bind(this))
+ }
+
+ onClick(event) {
+ event.preventDefault()
+ }
+ }
+ Button.DEPENDENCIES = DEPENDENCIES
+
+ return Button
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/FullscreenButton.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/FullscreenButton.js
new file mode 100644
index 0000000..fdb6045
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/FullscreenButton.js
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './ToggleButton' ], function (ToggleButton) {
+ 'use strict';
+
+ class FullscreenButton extends ToggleButton {
+ constructor(element, ...superDeps) {
+ super(element, false, 'Bastelstu.be/Chat/Ui/Settings/FullscreenButton', ...superDeps)
+ }
+
+ enable() {
+ super.enable()
+ document.querySelector('html').classList.add('fullscreen')
+ }
+
+ disable() {
+ super.disable()
+ document.querySelector('html').classList.remove('fullscreen')
+ }
+ }
+
+ return FullscreenButton
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/NotificationsButton.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/NotificationsButton.js
new file mode 100644
index 0000000..cf3c3cf
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/NotificationsButton.js
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './ToggleButton' ], function (ToggleButton) {
+ 'use strict';
+
+ const DEPENDENCIES = [ 'UiNotification' ].concat(ToggleButton.DEPENDENCIES || [ ])
+ class NotificationsButton extends ToggleButton {
+ constructor(element, notification, ...superDeps) {
+ super(element, false, 'Bastelstu.be/Chat/Ui/Settings/NotificationsButton', ...superDeps)
+
+ this.notification = notification
+ }
+
+ bootstrap() {
+ super.bootstrap()
+
+ // Hide the button if notifications are not supported or the permission has been denied
+ if (!this.notification.systemSupported || this.notification.systemDenied) {
+ elRemove(this.element.closest('li'))
+ }
+ }
+
+ enable() {
+ super.enable()
+ this.notification.enableSystemNotifications().catch(error => {
+ this.disable()
+
+ if (this.notification.systemDenied) elRemove(this.element)
+ })
+ }
+
+ disable() {
+ super.disable()
+ this.notification.disableSystemNotifications()
+ }
+ }
+ NotificationsButton.DEPENDENCIES = DEPENDENCIES
+
+ return NotificationsButton
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/SmiliesButton.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/SmiliesButton.js
new file mode 100644
index 0000000..cce9d60
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/SmiliesButton.js
@@ -0,0 +1,159 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './ToggleButton'
+ , 'WoltLabSuite/Core/Ui/Screen'
+ ], function (ToggleButton, UiScreen) {
+ 'use strict';
+
+ const DEPENDENCIES = [ 'UiInput' ].concat(ToggleButton.DEPENDENCIES || [ ])
+ class SmiliesButton extends ToggleButton {
+ constructor(element, input, ...superDeps) {
+ super(element, false, undefined, ...superDeps)
+
+ this.input = input
+ }
+
+ bootstrap() {
+ this.container = elById('smileyPickerContainer')
+
+ // Remove this button if smileys are disabled
+ if (!this.container) {
+ elRemove(this.element.closest('li'))
+ }
+
+ this.closeButton = elById('smileyPickerCloseButton')
+
+ // Initialize the smiley picker tab menu
+ $('.messageTabMenu').messageTabMenu()
+
+ $('#smilies-text').on('mousedown', '.jsSmiley', this.insertSmiley.bind(this))
+ this.closeButton.addEventListener('mousedown', this.disable.bind(this))
+
+ // Start in desktop mode
+ this.mobile = false
+
+ // Do not persist the state
+ super.bootstrap()
+
+ // Setup media queries
+ UiScreen.on('screen-md-down', {
+ match: this.enableMobile.bind(this),
+ unmatch: this.disableMobile.bind(this),
+ setup: this.setupMobile.bind(this)
+ })
+ }
+
+ /**
+ * Initializes and enables the mobile smiley picker UI components.
+ *
+ * A second button mirroring this button’s click handler is
+ * inserted next to the message input while this button will
+ * be hidden.
+ */
+ setupMobile() {
+ this.shadowToggleButton = document.createElement('span')
+ this.shadowToggleButton.classList.add('smiliesToggleMobileButton')
+ this.shadowToggleButton.innerHTML = ' '
+ this.shadowToggleButton.addEventListener('mousedown', this.onClick.bind(this))
+
+ const shadowContainer = elBySel('#chatInputContainer > div')
+ shadowContainer.insertBefore(this.shadowToggleButton, shadowContainer.firstChild)
+
+ this.enableMobile()
+ }
+
+ /**
+ * Enables the mobile smiley picker components.
+ *
+ * Hides this button and shows it’s mirror next to the message input.
+ */
+ enableMobile() {
+ this.mobile = true
+
+ elHide(this.element)
+ elShow(this.shadowToggleButton)
+
+ // Do not show the overlay when the viewport changes
+ // and becomes smaller
+ this.disable()
+ }
+
+ /**
+ * Disables the mobile smiley picker components.
+ *
+ * Shows this button and hides it’s mirror next to the message input.
+ * Also re-enables scrolling of the main body.
+ */
+ disableMobile() {
+ this.mobile = false
+
+ elShow(this.element)
+ elHide(this.shadowToggleButton)
+
+ UiScreen.scrollEnable()
+ }
+
+ /**
+ * Event handler to handle the insertion of smilies into the message input.
+ * This handler closes the fulls creen overlay of the mobile view after insertion.
+ *
+ * @param {Event} event The event bound in the init() function
+ */
+ insertSmiley(event) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ const smileyCode = event.currentTarget.children[0].getAttribute('alt')
+
+ this.input.insertText(` ${smileyCode} `)
+
+ if (this.mobile) {
+ this.disable()
+ }
+ }
+
+ /**
+ * Enables the smiley picker.
+ * If the mobile view is active, scrolling of the main body will be disabled.
+ */
+ enable() {
+ super.enable()
+
+ elShow(this.container)
+ elData(this.container, 'show', 'true')
+
+ if (this.mobile) {
+ UiScreen.scrollDisable()
+ }
+ }
+
+ /**
+ * Disables the smiley picker.
+ * If the mobile view is active, scrolling of the main body will be re-enabled.
+ */
+ disable() {
+ super.disable()
+
+ elHide(this.container)
+ elData(this.container, 'show', 'false')
+
+ if (this.mobile) {
+ UiScreen.scrollEnable()
+ }
+ }
+ }
+ SmiliesButton.DEPENDENCIES = DEPENDENCIES
+
+ return SmiliesButton
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/ToggleButton.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/ToggleButton.js
new file mode 100644
index 0000000..05c5139
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Settings/ToggleButton.js
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Button'
+ , '../../LocalStorage'
+ , '../../DataStructure/EventEmitter'
+ ], function (Button, LocalStorage, EventEmitter) {
+ 'use strict';
+
+ const DEPENDENCIES = [ ].concat(Button.DEPENDENCIES || [ ])
+ class ToggleButton extends Button {
+ constructor(element, defaultState, storageKey, ...superDeps) {
+ super(element, ...superDeps)
+
+ this.initialized = false
+ this.storage = new LocalStorage('Settings.')
+
+ this.storageKey = storageKey
+ if (this.storage.has(this.storageKey)) {
+ defaultState = this.storage.get(this.storageKey)
+ }
+
+ this.defaultState = defaultState
+ }
+
+ bootstrap() {
+ super.bootstrap()
+
+ if (this.defaultState) {
+ this.enable()
+ }
+ else {
+ this.disable()
+ }
+ }
+
+ get enabled() {
+ return this.element.classList.contains('active')
+ }
+
+ enable() {
+ this.element.classList.add('active')
+
+ if (this.storageKey != null) {
+ this.storage.set(this.storageKey, true)
+ }
+ }
+
+ disable() {
+ this.element.classList.remove('active')
+
+ if (this.storageKey != null) {
+ this.storage.set(this.storageKey, false)
+ }
+ }
+
+ onClick(event) {
+ super.onClick(event)
+
+ if (this.enabled) {
+ this.disable()
+ }
+ else {
+ this.enable()
+ }
+ }
+ }
+ EventEmitter(ToggleButton.prototype)
+ ToggleButton.DEPENDENCIES = DEPENDENCIES
+
+ return ToggleButton
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/Topic.js b/files_wcf/js/Bastelstu.be/Chat/Ui/Topic.js
new file mode 100644
index 0000000..ec98bde
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/Topic.js
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Dom/Traverse' ], function (Traverse) {
+ "use strict";
+
+ class Topic {
+ bootstrap() {
+ elBySelAll('.chatRoomTopic', document, function (element) {
+ elBySel('.jsDismissRoomTopicButton', element).addEventListener('click', function (event) {
+ elRemove(element)
+ })
+ })
+ }
+ }
+
+ return Topic
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/UserActionDropdownHandler.js b/files_wcf/js/Bastelstu.be/Chat/Ui/UserActionDropdownHandler.js
new file mode 100644
index 0000000..2f10760
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/UserActionDropdownHandler.js
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Dom/Traverse'
+ , 'WoltLabSuite/Core/Dom/Util'
+ , 'WoltLabSuite/Core/Ui/Dropdown/Simple'
+ ], function (DomTraverse, DomUtil, SimpleDropdown) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'ProfileStore', 'Template.UserListDropdownMenuItems', 'bottle' ]
+ class UserActionDropdownHandler {
+ constructor(profiles, dropdownTemplate, bottle) {
+ this.profiles = profiles
+ this.dropdownTemplate = dropdownTemplate
+ this.bottle = bottle
+
+ this.container = elById('main')
+ }
+
+ bootstrap() {
+ this.container.addEventListener('click', this.onClick.bind(this))
+ }
+
+ onClick(event) {
+ const userElement = event.target.classList.contains('jsUserActionDropdown') ? event.target : DomTraverse.parentByClass(event.target, 'jsUserActionDropdown', this.container)
+
+ if (!userElement) return
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ const user = this.profiles.get(parseInt(userElement.dataset.userId, 10))
+ if (user == null) {
+ throw new Error('Unreachable')
+ }
+
+ // Note: We would usually use firstElementChild here, but this
+ // is not supported in Safari and Edge
+ const dropdown = DomUtil.createFragmentFromHtml(this.dropdownTemplate.fetch({ user })).querySelector('*')
+
+ Array.from(elBySelAll('[data-module]', dropdown)).forEach(element => {
+ const moduleName = element.dataset.module
+ let userAction
+ if (!this.bottle.container.UserAction || (userAction = this.bottle.container.UserAction[`${moduleName.replace(/\./g, '-')}`]) == null) {
+ this.bottle.factory(`UserAction.${moduleName.replace(/\./g, '-')}`, _ => {
+ const UserAction = require(moduleName)
+ const deps = this.bottle.digest(UserAction.DEPENDENCIES || [])
+
+ return new UserAction(...deps)
+ })
+
+ userAction = this.bottle.container.UserAction[`${moduleName.replace(/\./g, '-')}`]
+ }
+
+ element.addEventListener(WCF_CLICK_EVENT, (event) => userAction.onClick(user, event))
+ })
+
+ SimpleDropdown.initFragment(userElement, dropdown)
+ SimpleDropdown.registerCallback(userElement.id, (container, action) => {
+ if (action === 'close') {
+ SimpleDropdown.destroy(container)
+ }
+ })
+ SimpleDropdown.toggleDropdown(userElement.id)
+ }
+ }
+ UserActionDropdownHandler.DEPENDENCIES = DEPENDENCIES
+
+ return UserActionDropdownHandler
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/UserActions/Action.js b/files_wcf/js/Bastelstu.be/Chat/Ui/UserActions/Action.js
new file mode 100644
index 0000000..402ca7e
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/UserActions/Action.js
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ ], function () {
+ "use strict";
+
+ class Action {
+ constructor() { }
+
+ onClick(userID, event) { }
+ }
+
+ return Action
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/UserActions/BanAction.js b/files_wcf/js/Bastelstu.be/Chat/Ui/UserActions/BanAction.js
new file mode 100644
index 0000000..e3a8114
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/UserActions/BanAction.js
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../../console'
+ , './Action'
+ ], function (console, Action) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'UiInput' ]
+ class BanAction extends Action {
+ constructor(input) {
+ super()
+
+ this.input = input
+ }
+
+ onClick(user, event) {
+ if (!event.target.dataset.trigger) {
+ console.warn('[WhisperAction]', `Missing trigger`)
+ return
+ }
+
+ const sanitizedUsername = user.username.replace(/"/g, '""')
+ const command = `/${event.target.dataset.trigger} "${sanitizedUsername}" `
+
+ this.input.insertText(command, { append: false, prepend: true })
+ this.input.focus()
+ setTimeout(_ => {
+ this.input.emit('autocomplete')
+ }, 1)
+ }
+ }
+ BanAction.DEPENDENCIES = DEPENDENCIES
+
+ return BanAction
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/UserActions/MuteAction.js b/files_wcf/js/Bastelstu.be/Chat/Ui/UserActions/MuteAction.js
new file mode 100644
index 0000000..f22ed78
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/UserActions/MuteAction.js
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ '../../console'
+ , './Action'
+ ], function (console, Action) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'UiInput' ]
+ class MuteAction extends Action {
+ constructor(input) {
+ super()
+
+ this.input = input
+ }
+
+ onClick(user, event) {
+ if (!event.target.dataset.trigger) {
+ console.warn('[WhisperAction]', `Missing trigger`)
+ return
+ }
+
+ const sanitizedUsername = user.username.replace(/"/g, '""')
+ const command = `/${event.target.dataset.trigger} "${sanitizedUsername}" `
+
+ this.input.insertText(command, { append: false, prepend: true })
+ this.input.focus()
+ setTimeout(_ => {
+ this.input.emit('autocomplete')
+ }, 1)
+ }
+ }
+ MuteAction.DEPENDENCIES = DEPENDENCIES
+
+ return MuteAction
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/UserActions/WhisperAction.js b/files_wcf/js/Bastelstu.be/Chat/Ui/UserActions/WhisperAction.js
new file mode 100644
index 0000000..3de94d9
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/UserActions/WhisperAction.js
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ './Action', '../../console' ], function (Action, console) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'UiInput' ]
+ class WhisperAction extends Action {
+ constructor(input) {
+ super()
+
+ this.input = input
+ }
+
+ onClick(user, event) {
+ if (!event.target.dataset.trigger) {
+ console.warn('[WhisperAction]', `Missing trigger`)
+ return
+ }
+
+ const sanitizedUsername = user.username.replace(/"/g, '""')
+ const command = `/${event.target.dataset.trigger} "${sanitizedUsername}" `
+
+ this.input.insertText(command, { append: false, prepend: true })
+ this.input.focus()
+ }
+ }
+ WhisperAction.DEPENDENCIES = DEPENDENCIES
+
+ return WhisperAction
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/Ui/UserList.js b/files_wcf/js/Bastelstu.be/Chat/Ui/UserList.js
new file mode 100644
index 0000000..f0157e4
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/Ui/UserList.js
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/Dom/Util' ], function (DomUtil) {
+ "use strict";
+
+ const DEPENDENCIES = [ 'Template.UserList' ]
+ class UserList {
+ constructor(userListTemplate) {
+ this.userListTemplate = userListTemplate
+ this.chatUserList = elById('chatUserList')
+ }
+
+ bootstrap() {
+
+ }
+
+ render(users) {
+ users.sort((a, b) => a.username.localeCompare(b.username))
+ const html = this.userListTemplate.fetch({ users })
+ const fragment = DomUtil.createFragmentFromHtml(html)
+
+ // Replace the current user list with the new one
+ const currentList = elBySel('#chatUserList > .boxContent > ul')
+ const parentNode = currentList.parentNode
+ parentNode.removeChild(currentList)
+ parentNode.appendChild(fragment)
+ }
+ }
+ UserList.DEPENDENCIES = DEPENDENCIES
+
+ return UserList
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/User.js b/files_wcf/js/Bastelstu.be/Chat/User.js
new file mode 100644
index 0000000..353f678
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/User.js
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ 'WoltLabSuite/Core/User'
+ , 'WoltLabSuite/Core/StringUtil'
+ , './Helper'
+ ], function (CoreUser, StringUtil, Helper) {
+ "use strict";
+
+ const u = Symbol('user')
+
+ /**
+ * Represents a user.
+ */
+ class User {
+ constructor(user) {
+ this[u] = Helper.deepFreeze(user)
+
+ Object.getOwnPropertyNames(this[u]).forEach(key => {
+ if (this[key]) {
+ throw new Error('Attempting to override existing property')
+ }
+
+ Object.defineProperty(this, key, { value: this[u][key]
+ , enumerable: true
+ })
+ })
+ }
+
+ get coloredUsername() {
+ // No color
+ if (this.color1 === null && this.color2 === null) return this.username
+
+ // Single color
+ if (this.color1 === this.color2) return `${StringUtil.escapeHTML(this.username)} `
+
+ // Gradient
+ const r1 = (this.color1 >> 16) & 0xFF
+ const r2 = (this.color2 >> 16) & 0xFF
+ const g1 = (this.color1 >> 8) & 0xFF
+ const g2 = (this.color2 >> 8) & 0xFF
+ const b1 = this.color1 & 0xFF
+ const b2 = this.color2 & 0xFF
+
+ const steps = this.username.length - 1
+ const r = (r1 - r2) / steps
+ const g = (g1 - g2) / steps
+ const b = (b1 - b2) / steps
+
+ return this[u].username.split('').map((letter, index) => {
+ const R = Math.round(r1 - index * r)
+ const G = Math.round(g1 - index * g)
+ const B = Math.round(b1 - index * b)
+
+ return `${StringUtil.escapeHTML(letter)} `
+ }).join('')
+ }
+
+ get self() {
+ return this.userID === CoreUser.userId
+ }
+
+ static getGuest(username) {
+ const payload = { username
+ , userID: null
+ , color1: null
+ , color2: null
+ }
+
+ return new User(payload)
+ }
+
+ wrap() {
+ return { user: this[u] }
+ }
+
+ toJSON() {
+ return this[u]
+ }
+ }
+
+ return User
+});
diff --git a/files_wcf/js/Bastelstu.be/Chat/console.js b/files_wcf/js/Bastelstu.be/Chat/console.js
new file mode 100644
index 0000000..035b153
--- /dev/null
+++ b/files_wcf/js/Bastelstu.be/Chat/console.js
@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 2010-2018 Tim Düsterhus.
+ *
+ * Use of this software is governed by the Business Source License
+ * included in the LICENSE file.
+ *
+ * Change Date: 2022-08-16
+ *
+ * On the date above, in accordance with the Business Source
+ * License, use of this software will be governed by version 2
+ * or later of the General Public License.
+ */
+
+define([ ], function () {
+ "use strict";
+
+ const start = Date.now()
+ let last = start
+
+ const group = function () {
+ if (window.console.group) window.console.group()
+ }
+
+ const groupCollapsed = function () {
+ if (window.console.groupCollapsed) window.console.groupCollapsed()
+ }
+
+ const groupEnd = function () {
+ if (window.console.groupEnd) window.console.groupEnd()
+ }
+
+ const println = function (type, ...args) {
+ window.console[type](...args)
+ }
+
+ const log = function (...args) {
+ println('log', ...args)
+ }
+
+ const warn = function (...args) {
+ println('warn', ...args)
+ }
+
+ const error = function (...args) {
+ println('error', ...args)
+ }
+
+ const debug = function (handler, ...args) {
+ const now = Date.now()
+ const time = [ (now - start), `\t+${(now - last)}ms\t` ]
+
+ if (args.length) {
+ println('debug', ...time, `[${handler}]\t`, ...args)
+ }
+ else {
+ println('debug', ...time, handler)
+ }
+
+ last = now
+ }
+
+ const debugException = function (error) {
+ if (error instanceof Error) {
+ let message = `[${error.name}] „${error.message}“ in ${error.fileName} on line ${error.lineNumber}\n`
+
+ if (error.stack) {
+ message += 'Stacktrace:\n'
+ message += error.stack
+ }
+
+ println('error', message)
+ }
+ else if (error.code && error.message) {
+ debugAjaxException(error)
+ }
+ }
+
+ const debugAjaxException = function (error) {
+ groupCollapsed()
+ let details = `[${error.code}] ${error.message}`
+
+ const br2nl = (string) => string.split('\n')
+ .map(line => line.replace(/ $/i, ''))
+ .join('\n')
+
+ if (error.stacktrace) {
+ details += `\nStacktrace:\n${br2nl(error.stacktrace)}`
+ }
+ else if (error.exceptionID) {
+ details += `\nException ID: ${error.exceptionID}`
+ }
+
+ println('debug', details)
+
+ error.previous.forEach(previous => {
+ let details = ''
+
+ group()
+
+ details += `${previous.message}\n`
+ details += `Stacktrace:\n${br2nl(previous.stacktrace)}`
+
+ println('debug', details)
+ })
+
+ error.previous.forEach(_ => groupEnd())
+ groupEnd()
+ }
+
+ return { log
+ , warn
+ , error
+ , debug
+ , debugException
+ , group
+ , groupCollapsed
+ , groupEnd
+ }
+});
diff --git a/files_wcf/lib/system/package/plugin/ChatCommandPackageInstallationPlugin.class.php b/files_wcf/lib/system/package/plugin/ChatCommandPackageInstallationPlugin.class.php
new file mode 100644
index 0000000..0766527
--- /dev/null
+++ b/files_wcf/lib/system/package/plugin/ChatCommandPackageInstallationPlugin.class.php
@@ -0,0 +1,172 @@
+application.WCF_N."_".$this->tableName."
+ WHERE packageID = ?
+ AND identifier = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+
+ WCF::getDB()->beginTransaction();
+ foreach ($items as $item) {
+ $statement->execute([
+ $this->installation->getPackageID(),
+ $item['attributes']['name']
+ ]);
+ }
+ WCF::getDB()->commitTransaction();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getElement(\DOMXPath $xpath, array &$elements, \DOMElement $element) {
+ $nodeValue = $element->nodeValue;
+
+ if ($element->tagName === 'triggers') {
+ $nodeValue = [ ];
+ $triggers = $xpath->query('child::*', $element);
+
+ foreach ($triggers as $trigger) {
+ $nodeValue[] = $trigger->nodeValue;
+ }
+ }
+
+ $elements[$element->tagName] = $nodeValue;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function prepareImport(array $data) {
+ return [
+ 'identifier' => $data['attributes']['name'],
+ 'className' => $data['elements']['classname'],
+ 'triggers' => isset($data['elements']['triggers']) ? $data['elements']['triggers'] : [ ]
+ ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function validateImport(array $data) {
+ if ($data['identifier'] === '') {
+ throw new SystemException('Command identifier (name attribute) may not be empty');
+ }
+ if (!class_exists($data['className'])) {
+ throw new SystemException("'".$data['className']."' does not exist.");
+ }
+ if (!\wcf\util\ClassUtil::isInstanceOf($data['className'], \chat\system\command\ICommand::class)) {
+ throw new SystemException("'".$data['className']."' does not implement '\chat\system\command\ICommand.'");
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function findExistingItem(array $data) {
+ $sql = "SELECT *
+ FROM ".$this->application.WCF_N."_".$this->tableName."
+ WHERE packageID = ?
+ AND identifier = ?";
+ $parameters = [
+ $this->installation->getPackageID(),
+ $data['identifier']
+ ];
+
+ return [
+ 'sql' => $sql,
+ 'parameters' => $parameters
+ ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function import(array $row, array $data) {
+ $triggers = $data['triggers'];
+ unset($data['triggers']);
+
+ $result = parent::import($row, $data);
+
+ if (empty($row)) {
+ // import initial triggers
+ $sql = "INSERT INTO ".$this->application.WCF_N."_command_trigger (commandTrigger, commandID)
+ VALUES (?, ?)";
+ $statement = WCF::getDB()->prepareStatement($sql);
+
+ try {
+ WCF::getDB()->beginTransaction();
+
+ foreach ($triggers as $trigger) {
+ try {
+ $statement->execute(array(
+ $trigger,
+ $result->commandID
+ ));
+ }
+ catch (\wcf\system\database\DatabaseException $e) {
+ // Duplicate key errors don't cause harm.
+ if ((string) $e->getCode() !== '23000') throw $e;
+ }
+ }
+
+ WCF::getDB()->commitTransaction();
+ }
+ catch (\Exception $e) {
+ WCF::getDB()->rollBackTransaction();
+ throw $e;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public static function getSyncDependencies() {
+ return [ 'file' ];
+ }
+}
diff --git a/language/de.xml b/language/de.xml
new file mode 100644
index 0000000..a9c6d86
--- /dev/null
+++ b/language/de.xml
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - {$room} wirklich löschen?]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - /{$trigger->commandTrigger} wirklich löschen?]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - {lang}chat.acp.suspension.type.{$suspension->getSuspensionType()->objectType}{/lang} von {$suspension->getUser()->username} wirklich zurück ziehen?]]>
+ - revoker}, {$suspension->revoked|plainTime}.]]>
+
+
+
+
+
+
+
+ - Raumübersicht, um einen Chatraum zu betreten.]]>
+
+
+
+
+
+ - Der Chat wurde aufgrund von anhaltenden Verbindungsproblemen oder einem anderen schwerwiegenden Problem deaktiviert{if $err.message}: {$err.message}{else}.{/if}
{if $err.exceptionID}Exception ID: {$err.exceptionID}
{/if}]]>
+ - Der Chat konnte nicht ordnungsgemäß initialisiert werden{if $err.message}: {$err.message}{else}.{/if}{if $err.exceptionID}
Exception ID: {$err.exceptionID}
{/if}]]>
+
+ - = $data.parameterString.length}{if $__window.LANGUAGE_USE_INFORMAL_VARIANT}Du hast{else}Sie haben{/if} nicht alle notwendigen Parameter übergeben{else}Die angegebenen Parameter sind an der Position „{$data.parameterString.substr($data.result.offset, 5)}“ ungültig{/if}.]]>
+
+
+
+
+
+ - username}“ blockiert {if LANGUAGE_USE_INFORMAL_VARIANT}dich{else}Sie{/if}.]]>
+
+
+
+
+
+
+
+
+
+
+
+ - {@$author.coloredUsername} ist jetzt abwesend{if $message.payload.message}: {@$message.payload.message}{/if}.]]>
+
+ - {@$author.coloredUsername} ist nun zurück.]]>
+
+
+
+
+ - {@$author.coloredUsername} hat den Chat betreten.]]>
+
+ - {@$author.coloredUsername} hat den Chat verlassen.]]>
+
+
+ - {$message.payload.room.title}“ wurde erfolgreich erstellt.]]>
+ - {@$author.coloredUsername} hat {if LANGUAGE_USE_INFORMAL_VARIANT}dich{else}Sie{/if} in den temporären Raum „
{$message.payload.room.title} “ eingeladen.]]>
+
+
+
+
+
+
+
+
+
+
+
+
+ - Tims Chat{if SHOW_VERSION_NUMBER} v{@PACKAGE_VERSION}{/if}]]>
+
+
+
+
+
+
+
+
+
+
+
+ - username})]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - {@$author.coloredUsername} hat
{@$message.payload.target.username} {if $message.payload.globally} global{/if} {if $message.payload.suspension.expires === null}für immer{else}bis {$message.payload.suspension.formattedExpires}{/if} gebannt{if $message.payload.suspension.reason}: {$message.payload.suspension.reason}{else}.{/if}]]>
+ - {@$author.coloredUsername} hat
{@$message.payload.target.username} {if $message.payload.globally} global{/if} {if $message.payload.suspension.expires === null}für immer{else}bis {$message.payload.suspension.formattedExpires}{/if} geknebelt{if $message.payload.suspension.reason}: {$message.payload.suspension.reason}{else}.{/if}]]>
+ - {@$author.coloredUsername} hat
{@$message.payload.target.username} {if $message.payload.globally} global{/if} entbannt.]]>
+ - {@$author.coloredUsername} hat
{@$message.payload.target.username} {if $message.payload.globally} global{/if} entknebelt.]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - nicht verwendet werden.]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - getLink()}">{$room})]]>
+ - getLink()}">{$room}]]>
+
+
+
+
+
+
+
+
+
diff --git a/language/en.xml b/language/en.xml
new file mode 100644
index 0000000..8aa7cfa
--- /dev/null
+++ b/language/en.xml
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - {$room}?]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - /{$trigger->commandTrigger}?]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - {lang}chat.acp.suspension.type.{$suspension->getSuspensionType()->objectType}{/lang} of {$suspension->getUser()->username}?]]>
+ - revoker}, {$suspension->revoked|plainTime}.]]>
+
+
+
+
+
+
+
+ - Room Overview to enter a chat room.]]>
+
+
+
+
+
+ - The chat was shut down because of persisting connection problems or another serious error{if $err.message}: {$err.message}{else}.{/if}{if $err.exceptionID}
Exception ID: {$err.exceptionID}
{/if}]]>
+ - The chat could not be properly initialized{if $err.message}: {$err.message}{else}.{/if}{if $err.exceptionID}
Exception ID: {$err.exceptionID}
{/if}]]>
+
+ - = $data.parameterString.length}There are parameters missing to the given command{else}The parameters to the given command are invalid at “{$data.parameterString.substr($data.result.offset, 5)}”{/if}.]]>
+
+
+
+
+
+ - username}” is blocking you.]]>
+
+
+
+
+
+
+
+
+
+
+
+ - {@$author.coloredUsername} is now away{if $message.payload.message}: {@$message.payload.message}{/if}.]]>
+
+ - {@$author.coloredUsername} is now back.]]>
+
+
+
+
+ - {@$author.coloredUsername} joined.]]>
+
+ - {@$author.coloredUsername} left.]]>
+
+
+ - {$message.payload.room.title}” has been created successfully.]]>
+ - {@$author.coloredUsername} invited you to the temporary room “
{$message.payload.room.title} ”.]]>
+
+
+
+
+
+
+
+
+
+
+
+
+ - Tim’s Chat{if SHOW_VERSION_NUMBER} v{@PACKAGE_VERSION}{/if}]]>
+
+
+
+
+
+
+
+
+
+
+
+ - username})]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - {@$author.coloredUsername} banned
{@$message.payload.target.username} {if $message.payload.globally} globally{/if} {if $message.payload.suspension.expires === null}forever{else}until {$message.payload.suspension.formattedExpires}{/if}{if $message.payload.suspension.reason}: {$message.payload.suspension.reason}{else}.{/if}]]>
+ - {@$author.coloredUsername} muted
{@$message.payload.target.username} {if $message.payload.globally} globally{/if} {if $message.payload.suspension.expires === null}forever{else}until {$message.payload.suspension.formattedExpires}{/if}{if $message.payload.suspension.reason}: {$message.payload.suspension.reason}{else}.{/if}]]>
+ - {@$author.coloredUsername} unbanned
{@$message.payload.target.username} {if $message.payload.globally} globally{/if}.]]>
+ - {@$author.coloredUsername} unmuted
{@$message.payload.target.username} {if $message.payload.globally} globally{/if}.]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - cannot be used by the users of this user group.]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - getLink()}">{$room})]]>
+ - getLink()}">{$room}]]>
+
+
+
+
+
+
+
+
+
diff --git a/menuItem.xml b/menuItem.xml
new file mode 100644
index 0000000..c605625
--- /dev/null
+++ b/menuItem.xml
@@ -0,0 +1,11 @@
+
+
+
+ -
+
com.woltlab.wcf.MainMenu
+ Chat
+ Chat
+ be.bastelstu.chat.RoomList
+
+
+
diff --git a/objectType.xml b/objectType.xml
new file mode 100644
index 0000000..d0699cd
--- /dev/null
+++ b/objectType.xml
@@ -0,0 +1,172 @@
+
+
+
+
+
+ be.bastelstu.chat.room
+ com.woltlab.wcf.acl
+
+
+
+
+
+ be.bastelstu.chat.roomList
+ com.woltlab.wcf.boxController
+ chat\system\box\RoomListBoxController
+
+
+
+
+
+ be.bastelstu.chat.roomFilled
+ be.bastelstu.chat.box.roomList.condition
+ chat\system\condition\room\RoomFilledCondition
+
+
+
+
+
+ be.bastelstu.chat.messageType.away
+ be.bastelstu.chat.messageType
+ chat\system\message\type\AwayMessageType
+
+
+
+ be.bastelstu.chat.messageType.back
+ be.bastelstu.chat.messageType
+ chat\system\message\type\BackMessageType
+
+
+
+ be.bastelstu.chat.messageType.broadcast
+ be.bastelstu.chat.messageType
+ chat\system\message\type\BroadcastMessageType
+
+
+
+ be.bastelstu.chat.messageType.chatUpdate
+ be.bastelstu.chat.messageType
+ chat\system\message\type\ChatUpdateMessageType
+
+
+
+ be.bastelstu.chat.messageType.color
+ be.bastelstu.chat.messageType
+ chat\system\message\type\ColorMessageType
+
+
+
+ be.bastelstu.chat.messageType.info
+ be.bastelstu.chat.messageType
+ chat\system\message\type\InfoMessageType
+
+
+
+ be.bastelstu.chat.messageType.join
+ be.bastelstu.chat.messageType
+ chat\system\message\type\JoinMessageType
+
+
+
+ be.bastelstu.chat.messageType.leave
+ be.bastelstu.chat.messageType
+ chat\system\message\type\LeaveMessageType
+
+
+
+ be.bastelstu.chat.messageType.me
+ be.bastelstu.chat.messageType
+ chat\system\message\type\MeMessageType
+
+
+
+ be.bastelstu.chat.messageType.plain
+ be.bastelstu.chat.messageType
+ chat\system\message\type\PlainMessageType
+
+
+
+ be.bastelstu.chat.messageType.suspend
+ be.bastelstu.chat.messageType
+ chat\system\message\type\SuspendMessageType
+
+
+
+ be.bastelstu.chat.messageType.team
+ be.bastelstu.chat.messageType
+ chat\system\message\type\TeamMessageType
+
+
+
+ be.bastelstu.chat.messageType.temproomCreated
+ be.bastelstu.chat.messageType
+ chat\system\message\type\TemproomCreatedMessageType
+
+
+
+ be.bastelstu.chat.messageType.temproomInvited
+ be.bastelstu.chat.messageType
+ chat\system\message\type\TemproomInvitedMessageType
+
+
+
+ be.bastelstu.chat.messageType.tombstone
+ be.bastelstu.chat.messageType
+ chat\system\message\type\TombstoneMessageType
+
+
+
+ be.bastelstu.chat.messageType.unsuspend
+ be.bastelstu.chat.messageType
+ chat\system\message\type\UnsuspendMessageType
+
+
+
+ be.bastelstu.chat.messageType.where
+ be.bastelstu.chat.messageType
+ chat\system\message\type\WhereMessageType
+
+
+
+ be.bastelstu.chat.messageType.whisper
+ be.bastelstu.chat.messageType
+ chat\system\message\type\WhisperMessageType
+
+
+
+
+
+ be.bastelstu.chat.suspension.ban
+ be.bastelstu.chat.suspension
+ chat\system\suspension\BanSuspension
+
+
+
+ be.bastelstu.chat.suspension.mute
+ be.bastelstu.chat.suspension
+ chat\system\suspension\MuteSuspension
+
+
+
+
+
+ be.bastelstu.chat.message
+ com.woltlab.wcf.message
+
+
+
+
+
+ be.bastelstu.chat.activityPointEvent.join
+ com.woltlab.wcf.user.activityPointEvent
+ 10
+
+
+
+ be.bastelstu.chat.activityPointEvent.message
+ com.woltlab.wcf.user.activityPointEvent
+ 1
+
+
+
+
diff --git a/objectTypeDefinition.xml b/objectTypeDefinition.xml
new file mode 100644
index 0000000..cf46501
--- /dev/null
+++ b/objectTypeDefinition.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ be.bastelstu.chat.messageType
+ chat\system\message\type\IMessageType
+
+
+
+ be.bastelstu.chat.box.roomList.condition
+ wcf\system\condition\IObjectListCondition
+
+
+
+ be.bastelstu.chat.suspension
+ chat\system\suspension\ISuspension
+
+
+
diff --git a/option.xml b/option.xml
new file mode 100644
index 0000000..b108554
--- /dev/null
+++ b/option.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+ chat
+
+
+
+
+
+ chat.general
+ integer
+ 3
+ 0
+ 5
+ seconds
+
+
+
+ chat.general
+ integer
+ 0
+ 0
+ 1440
+ minutes
+
+
+
+ chat.general
+ integer
+ 500
+ 50
+ 5000
+
+
+
+ chat.general
+ integer
+ 90
+ 30
+ 600
+ seconds
+
+
+
+ chat.general
+ integer
+ 7
+ 0
+ days
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7db2afb
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+ "dependencies": {
+ "babel-cli": "^6.26.0",
+ "babel-preset-env": "^1.7.0",
+ "requirejs": "^2.3.5",
+ "terser": "^3.8.1"
+ }
+}
diff --git a/package.xml b/package.xml
new file mode 100644
index 0000000..17d611a
--- /dev/null
+++ b/package.xml
@@ -0,0 +1,113 @@
+
+
+
+ Tim’s Chat
+ Tims Chat
+ 1
+ chat
+ 4.0.0 RC 2
+ 2018-08-16
+
+
+
+ Tim Düsterhus
+ http://tims.bastelstu.be
+
+
+
+ com.woltlab.wcf
+ be.bastelstu.core-js
+ be.bastelstu.promiseWrap
+ be.bastelstu.parserCombinator
+ be.bastelstu.bottle
+ be.bastelstu.wcf.push
+
+
+
+
+
+
+
+
+ sql/0001-chat1_room.sql
+ sql/0002-Default-Room.sql
+ sql/0003-chat1_room_to_user.sql
+ sql/0004-chat1_message.sql
+ sql/0005-chat1_room_to_user-FOREIGN_KEY.sql
+ sql/0006-chat1_room_to_user-Timestamps.sql
+ sql/0007-chat1_room_to_user_lastPull.sql
+ sql/0008-chat1_message-Username-Width.sql
+ sql/0009-chat1_command.sql
+ sql/0010-chat1_command_trigger.sql
+ sql/0011-chat1_session.sql
+ sql/0012-chat1_message-Nullroom.sql
+ sql/0013-chat1_session-Index.sql
+ sql/0014-chat1_message-Embedded-Objects.sql
+ sql/0015-chat1_user-Away.sql
+ sql/0016-chat1_command_trigger-PRIMARY_KEY.sql
+ sql/0017-chat1_command-Unique-className.sql
+ sql/0018-wcf1_user-Color.sql
+ sql/0019-chat1_room-User-Limit.sql
+ sql/0019-chat1_suspension.sql
+ sql/0020-chat1_suspension-Revoked.sql
+ sql/0021-chat1_room-Temporary.sql
+ sql/0022-chat1_room_temporary_invite.sql
+ sql/0023-chat1_message-isDeleted.sql
+ sql/0024-chat1_room-topicUseHtml.sql
+ sql/0025-chat1_room-topic-text.sql
+
+ files_wcf.tar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ acp/be.bastelstu.chat_install.php
+
+
+
+ files_wcf.tar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ acp/be.bastelstu.chat_update.php
+
+
diff --git a/packageInstallationPlugin.xml b/packageInstallationPlugin.xml
new file mode 100644
index 0000000..2212b56
--- /dev/null
+++ b/packageInstallationPlugin.xml
@@ -0,0 +1,6 @@
+
+
+
+ wcf\system\package\plugin\ChatCommandPackageInstallationPlugin
+
+
diff --git a/page.xml b/page.xml
new file mode 100644
index 0000000..cd456b0
--- /dev/null
+++ b/page.xml
@@ -0,0 +1,47 @@
+
+
+
+
+ system
+ chat\page\RoomListPage
+ chat\system\page\handler\RoomListPageHandler
+ Chatraum-Liste
+ Chat Room List
+
+
+ Chat
+
+
+ Chat
+
+
+
+
+ system
+ chat\page\RoomPage
+ chat\system\page\handler\RoomPageHandler
+ Chatraum
+ Chat Room
+ 1
+ be.bastelstu.chat.RoomList
+
+
+
+ system
+ chat\page\LogPage
+ chat\system\page\handler\LogPageHandler
+ Chatlog
+ Chat Log
+ 1
+ 1
+ be.bastelstu.chat.Room
+
+
+ Chat Log
+
+
+ Chatlog
+
+
+
+
diff --git a/require.build.js b/require.build.js
new file mode 100644
index 0000000..f170a6c
--- /dev/null
+++ b/require.build.js
@@ -0,0 +1,73 @@
+(function () {
+ var config = {
+ name: "_Meta",
+ out: "Bastelstu.be.Chat.js",
+ useStrict: true,
+ preserveLicenseComments: false,
+ optimize: 'none',
+ excludeShallow: [
+ '_Meta'
+ ],
+ rawText: {
+ '_Meta': 'define([], function() {});'
+ },
+ paths: {
+ 'Bastelstu.be': 'files_wcf/js/Bastelstu.be'
+ },
+ onBuildRead: function(moduleName, path, contents) {
+ if (!process.versions.node) {
+ throw new Error('You need to run node.js');
+ }
+
+ if (moduleName === '_Meta') {
+ if (global.allModules === undefined) {
+ var fs = module.require('fs'),
+ path = module.require('path');
+ global.allModules = [];
+
+ var queue = ['Bastelstu.be'];
+ var folder;
+ while (folder = queue.shift()) {
+ var files = fs.readdirSync('files_wcf/js/' + folder);
+ for (var i = 0; i < files.length; i++) {
+ var filename = path.join(folder, files[i]).replace(/\\/g, '/');
+
+ if (path.extname(filename) === '.js') {
+ global.allModules.push(filename);
+ }
+ else if (fs.statSync('files_wcf/js/' + filename).isDirectory()) {
+ queue.push(filename);
+ }
+ }
+ }
+ }
+
+ return 'define([' + global.allModules.map(function (item) { return "'" + item.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\.js$/, '') + "'"; }).join(', ') + '], function() { });';
+ }
+
+ return contents;
+ }
+ };
+
+ var _isSupportedBuildUrl = require._isSupportedBuildUrl;
+ require._isSupportedBuildUrl = function (url) {
+ var result = _isSupportedBuildUrl(url);
+ if (!result) return result;
+ if (Object.keys(config.rawText).some(module => url.endsWith(`${module}.js`))) return result;
+
+ var fs = module.require('fs');
+ try {
+ fs.statSync(url);
+ }
+ catch (e) {
+ console.log('Unable to find module:', url, 'ignoring.');
+
+ return false;
+ }
+ return true;
+ };
+
+ if (module) module.exports = config;
+
+ return config;
+})();
diff --git a/sql/0001-chat1_room.sql b/sql/0001-chat1_room.sql
new file mode 100644
index 0000000..ef9edf1
--- /dev/null
+++ b/sql/0001-chat1_room.sql
@@ -0,0 +1,5 @@
+CREATE TABLE chat1_room ( roomID INT(10) NOT NULL AUTO_INCREMENT PRIMARY KEY
+ , title VARCHAR(255) NOT NULL
+ , topic VARCHAR(255) NOT NULL
+ , position SMALLINT(5) NOT NULL
+ );
diff --git a/sql/0002-Default-Room.sql b/sql/0002-Default-Room.sql
new file mode 100644
index 0000000..ebfcd75
--- /dev/null
+++ b/sql/0002-Default-Room.sql
@@ -0,0 +1 @@
+INSERT INTO chat1_room (title, topic, position) VALUES ('Default', '', 0);
diff --git a/sql/0003-chat1_room_to_user.sql b/sql/0003-chat1_room_to_user.sql
new file mode 100644
index 0000000..71d8aa1
--- /dev/null
+++ b/sql/0003-chat1_room_to_user.sql
@@ -0,0 +1,6 @@
+CREATE TABLE chat1_room_to_user ( roomID INT(10) NOT NULL
+ , userID INT(10) NOT NULL
+
+ , PRIMARY KEY (roomID, userID)
+ , KEY (userID)
+ );
diff --git a/sql/0004-chat1_message.sql b/sql/0004-chat1_message.sql
new file mode 100644
index 0000000..c6a341c
--- /dev/null
+++ b/sql/0004-chat1_message.sql
@@ -0,0 +1,16 @@
+CREATE TABLE chat1_message ( messageID INT(10) NOT NULL AUTO_INCREMENT PRIMARY KEY
+ , time INT(10) NOT NULL
+ , roomID INT(10) NOT NULL
+ , userID INT(10) DEFAULT NULL
+ , username VARCHAR(255) NOT NULL
+ , objectTypeID INT(10) NOT NULL
+ , payload MEDIUMBLOB NOT NULL
+
+ , KEY (roomID)
+ , KEY (userID)
+ , KEY (time)
+ );
+
+ALTER TABLE chat1_message ADD FOREIGN KEY (userID) REFERENCES wcf1_user (userID) ON DELETE SET NULL;
+ALTER TABLE chat1_message ADD FOREIGN KEY (roomID) REFERENCES chat1_room (roomID) ON DELETE CASCADE;
+ALTER TABLE chat1_message ADD FOREIGN KEY (objectTypeID) REFERENCES wcf1_object_type (objectTypeID) ON DELETE CASCADE;
diff --git a/sql/0005-chat1_room_to_user-FOREIGN_KEY.sql b/sql/0005-chat1_room_to_user-FOREIGN_KEY.sql
new file mode 100644
index 0000000..2a3a397
--- /dev/null
+++ b/sql/0005-chat1_room_to_user-FOREIGN_KEY.sql
@@ -0,0 +1,2 @@
+ALTER TABLE chat1_room_to_user ADD FOREIGN KEY (roomID) REFERENCES chat1_room (roomID) ON DELETE CASCADE;
+ALTER TABLE chat1_room_to_user ADD FOREIGN KEY (userID) REFERENCES wcf1_user (userID) ON DELETE CASCADE;
diff --git a/sql/0006-chat1_room_to_user-Timestamps.sql b/sql/0006-chat1_room_to_user-Timestamps.sql
new file mode 100644
index 0000000..77393bb
--- /dev/null
+++ b/sql/0006-chat1_room_to_user-Timestamps.sql
@@ -0,0 +1,5 @@
+ALTER TABLE chat1_room_to_user ADD lastFetch INT(10) NOT NULL DEFAULT 0;
+ALTER TABLE chat1_room_to_user ADD lastPush INT(10) NOT NULL DEFAULT 0;
+ALTER TABLE chat1_room_to_user ADD active TINYINT(1) NOT NULL DEFAULT 0;
+ALTER TABLE chat1_room_to_user ADD KEY (roomID, active);
+ALTER TABLE chat1_room_to_user ADD KEY (active);
diff --git a/sql/0007-chat1_room_to_user_lastPull.sql b/sql/0007-chat1_room_to_user_lastPull.sql
new file mode 100644
index 0000000..4aba540
--- /dev/null
+++ b/sql/0007-chat1_room_to_user_lastPull.sql
@@ -0,0 +1 @@
+ALTER TABLE chat1_room_to_user CHANGE lastFetch lastPull INT(10) NOT NULL DEFAULT 0;
diff --git a/sql/0008-chat1_message-Username-Width.sql b/sql/0008-chat1_message-Username-Width.sql
new file mode 100644
index 0000000..0137e57
--- /dev/null
+++ b/sql/0008-chat1_message-Username-Width.sql
@@ -0,0 +1 @@
+ALTER TABLE chat1_message CHANGE username username VARCHAR(100) NOT NULL;
diff --git a/sql/0009-chat1_command.sql b/sql/0009-chat1_command.sql
new file mode 100644
index 0000000..7a152d4
--- /dev/null
+++ b/sql/0009-chat1_command.sql
@@ -0,0 +1,9 @@
+CREATE TABLE chat1_command ( commandID INT(10) NOT NULL AUTO_INCREMENT PRIMARY KEY
+ , packageID INT(10) NOT NULL
+ , identifier VARCHAR(191) NOT NULL
+ , className VARCHAR(191) NOT NULL
+
+ , UNIQUE KEY command (packageID, identifier)
+ );
+
+ALTER TABLE chat1_command ADD FOREIGN KEY (packageID) REFERENCES wcf1_package (packageID) ON DELETE CASCADE;
diff --git a/sql/0010-chat1_command_trigger.sql b/sql/0010-chat1_command_trigger.sql
new file mode 100644
index 0000000..4b714c0
--- /dev/null
+++ b/sql/0010-chat1_command_trigger.sql
@@ -0,0 +1,7 @@
+CREATE TABLE chat1_command_trigger ( commandTrigger VARCHAR(191) NOT NULL PRIMARY KEY
+ , commandID INT(10) NOT NULL
+
+ , KEY commandID (commandID)
+ );
+
+ALTER TABLE chat1_command_trigger ADD FOREIGN KEY (commandID) REFERENCES chat1_command (commandID) ON DELETE CASCADE;
diff --git a/sql/0011-chat1_session.sql b/sql/0011-chat1_session.sql
new file mode 100644
index 0000000..66690df
--- /dev/null
+++ b/sql/0011-chat1_session.sql
@@ -0,0 +1,12 @@
+CREATE TABLE chat1_session ( roomID INT(10) NOT NULL
+ , userID INT(10) NOT NULL
+ , sessionID BINARY(16) NOT NULL
+ , lastRequest INT(10) NOT NULL
+
+ , PRIMARY KEY (roomID, userID, sessionID)
+ , KEY (userID, sessionID)
+ , KEY (sessionID)
+ );
+
+ALTER TABLE chat1_session ADD FOREIGN KEY (roomID) REFERENCES chat1_room (roomID) ON DELETE CASCADE;
+ALTER TABLE chat1_session ADD FOREIGN KEY (userID) REFERENCES wcf1_user (userID) ON DELETE CASCADE;
diff --git a/sql/0012-chat1_message-Nullroom.sql b/sql/0012-chat1_message-Nullroom.sql
new file mode 100644
index 0000000..4a3bc67
--- /dev/null
+++ b/sql/0012-chat1_message-Nullroom.sql
@@ -0,0 +1 @@
+ALTER TABLE chat1_message CHANGE roomID roomID INT(10) DEFAULT NULL;
diff --git a/sql/0013-chat1_session-Index.sql b/sql/0013-chat1_session-Index.sql
new file mode 100644
index 0000000..3479f15
--- /dev/null
+++ b/sql/0013-chat1_session-Index.sql
@@ -0,0 +1 @@
+ALTER TABLE chat1_session ADD KEY (lastRequest);
diff --git a/sql/0014-chat1_message-Embedded-Objects.sql b/sql/0014-chat1_message-Embedded-Objects.sql
new file mode 100644
index 0000000..84fbc37
--- /dev/null
+++ b/sql/0014-chat1_message-Embedded-Objects.sql
@@ -0,0 +1 @@
+ALTER TABLE chat1_message ADD hasEmbeddedObjects TINYINT(1) NOT NULL DEFAULT 0;
diff --git a/sql/0015-chat1_user-Away.sql b/sql/0015-chat1_user-Away.sql
new file mode 100644
index 0000000..381759b
--- /dev/null
+++ b/sql/0015-chat1_user-Away.sql
@@ -0,0 +1 @@
+ALTER TABLE wcf1_user ADD COLUMN chatAway TEXT DEFAULT NULL;
diff --git a/sql/0016-chat1_command_trigger-PRIMARY_KEY.sql b/sql/0016-chat1_command_trigger-PRIMARY_KEY.sql
new file mode 100644
index 0000000..410156a
--- /dev/null
+++ b/sql/0016-chat1_command_trigger-PRIMARY_KEY.sql
@@ -0,0 +1,3 @@
+ALTER TABLE chat1_command_trigger DROP PRIMARY KEY;
+ALTER TABLE chat1_command_trigger ADD COLUMN triggerID INT(10) NOT NULL AUTO_INCREMENT PRIMARY KEY;
+ALTER TABLE chat1_command_trigger ADD UNIQUE KEY (commandTrigger);
diff --git a/sql/0017-chat1_command-Unique-className.sql b/sql/0017-chat1_command-Unique-className.sql
new file mode 100644
index 0000000..a55127e
--- /dev/null
+++ b/sql/0017-chat1_command-Unique-className.sql
@@ -0,0 +1 @@
+ALTER TABLE chat1_command ADD UNIQUE KEY (className);
diff --git a/sql/0018-wcf1_user-Color.sql b/sql/0018-wcf1_user-Color.sql
new file mode 100644
index 0000000..1d4e2f1
--- /dev/null
+++ b/sql/0018-wcf1_user-Color.sql
@@ -0,0 +1,2 @@
+ALTER TABLE wcf1_user ADD COLUMN chatColor1 INT(10) DEFAULT NULL;
+ALTER TABLE wcf1_user ADD COLUMN chatColor2 INT(10) DEFAULT NULL;
diff --git a/sql/0019-chat1_room-User-Limit.sql b/sql/0019-chat1_room-User-Limit.sql
new file mode 100644
index 0000000..b0ad6dc
--- /dev/null
+++ b/sql/0019-chat1_room-User-Limit.sql
@@ -0,0 +1 @@
+ALTER TABLE chat1_room ADD COLUMN userLimit INT(10) NOT NULL DEFAULT 0;
diff --git a/sql/0019-chat1_suspension.sql b/sql/0019-chat1_suspension.sql
new file mode 100644
index 0000000..8e09018
--- /dev/null
+++ b/sql/0019-chat1_suspension.sql
@@ -0,0 +1,25 @@
+CREATE TABLE chat1_suspension ( suspensionID INT(10) NOT NULL AUTO_INCREMENT PRIMARY KEY
+ , time INT(10) NOT NULL
+ , expires INT(10) NULL
+ , roomID INT(10) NULL
+ , userID INT(10) NOT NULL
+ , objectTypeID INT(10) NOT NULL
+ , reason VARCHAR(255) NOT NULL
+ , judgeID INT(10) NULL
+ , judge VARCHAR(100) NOT NULL
+ , revoked TINYINT(1) NOT NULL DEFAULT 0
+ , revokerID INT(10) DEFAULT NULL
+ , revoker VARCHAR(100) DEFAULT NULL
+
+ , KEY (roomID, userID, objectTypeID)
+ , KEY (userID)
+ , KEY (objectTypeID, roomID)
+ , KEY (time)
+ , KEY (judgeID)
+ );
+
+ALTER TABLE chat1_suspension ADD FOREIGN KEY (roomID) REFERENCES chat1_room (roomID) ON DELETE CASCADE;
+ALTER TABLE chat1_suspension ADD FOREIGN KEY (userID) REFERENCES wcf1_user (userID) ON DELETE CASCADE;
+ALTER TABLE chat1_suspension ADD FOREIGN KEY (judgeID) REFERENCES wcf1_user (userID) ON DELETE SET NULL;
+ALTER TABLE chat1_suspension ADD FOREIGN KEY (revokerID) REFERENCES wcf1_user (userID) ON DELETE SET NULL;
+ALTER TABLE chat1_suspension ADD FOREIGN KEY (objectTypeID) REFERENCES wcf1_object_type (objectTypeID) ON DELETE CASCADE;
diff --git a/sql/0020-chat1_suspension-Revoked.sql b/sql/0020-chat1_suspension-Revoked.sql
new file mode 100644
index 0000000..b140e28
--- /dev/null
+++ b/sql/0020-chat1_suspension-Revoked.sql
@@ -0,0 +1,2 @@
+ALTER TABLE chat1_suspension CHANGE revoked revoked INT(10) DEFAULT NULL;
+UPDATE chat1_suspension SET revoked = NULL WHERE revoked = 0;
diff --git a/sql/0021-chat1_room-Temporary.sql b/sql/0021-chat1_room-Temporary.sql
new file mode 100644
index 0000000..fe1b4a4
--- /dev/null
+++ b/sql/0021-chat1_room-Temporary.sql
@@ -0,0 +1,4 @@
+ALTER TABLE chat1_room ADD isTemporary TINYINT(1) NOT NULL DEFAULT 0;
+ALTER TABLE chat1_room ADD ownerID INT(10) DEFAULT NULL;
+
+ALTER TABLE chat1_room ADD FOREIGN KEY (ownerID) REFERENCES wcf1_user (userID) ON DELETE SET NULL;
diff --git a/sql/0022-chat1_room_temporary_invite.sql b/sql/0022-chat1_room_temporary_invite.sql
new file mode 100644
index 0000000..8e114b5
--- /dev/null
+++ b/sql/0022-chat1_room_temporary_invite.sql
@@ -0,0 +1,8 @@
+CREATE TABLE chat1_room_temporary_invite ( roomID INT(10) NOT NULL
+ , userID INT(10) NOT NULL
+ , PRIMARY KEY (roomID, userID)
+ , KEY (userID)
+ );
+
+ALTER TABLE chat1_room_temporary_invite ADD FOREIGN KEY (roomID) REFERENCES chat1_room (roomID) ON DELETE CASCADE;
+ALTER TABLE chat1_room_temporary_invite ADD FOREIGN KEY (userID) REFERENCES wcf1_user (userID) ON DELETE CASCADE;
diff --git a/sql/0023-chat1_message-isDeleted.sql b/sql/0023-chat1_message-isDeleted.sql
new file mode 100644
index 0000000..191d914
--- /dev/null
+++ b/sql/0023-chat1_message-isDeleted.sql
@@ -0,0 +1 @@
+ALTER TABLE chat1_message ADD isDeleted TINYINT(1) NOT NULL DEFAULT 0;
diff --git a/sql/0024-chat1_room-topicUseHtml.sql b/sql/0024-chat1_room-topicUseHtml.sql
new file mode 100644
index 0000000..4f3ea45
--- /dev/null
+++ b/sql/0024-chat1_room-topicUseHtml.sql
@@ -0,0 +1 @@
+ALTER TABLE chat1_room ADD topicUseHtml TINYINT(1) NOT NULL DEFAULT 0;
diff --git a/sql/0025-chat1_room-topic-text.sql b/sql/0025-chat1_room-topic-text.sql
new file mode 100644
index 0000000..759ca43
--- /dev/null
+++ b/sql/0025-chat1_room-topic-text.sql
@@ -0,0 +1 @@
+ALTER TABLE chat1_room CHANGE topic topic TEXT NOT NULL;
diff --git a/templateListener.xml b/templateListener.xml
new file mode 100644
index 0000000..72d903e
--- /dev/null
+++ b/templateListener.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+ admin
+ index
+ softwareVersions
+
+
+
+
+
+
+ user
+ pageFooterCopyright
+ copyright
+
+
+
+
+ user
+ messageTypes
+ infoCommandContents
+
+
+
+ user
+ room
+ beforeBootstrap
+
+
+
+ user
+ room
+ language
+
+
+
+ user
+ userList
+ icons
+
+
+
+
+ user
+ messageTypes
+ messageTypes
+
+
+
+
+ user
+ messageTypes
+ language
+
+
+
+
+
diff --git a/templates/__chatCopyright.tpl b/templates/__chatCopyright.tpl
new file mode 100644
index 0000000..6b0d140
--- /dev/null
+++ b/templates/__chatCopyright.tpl
@@ -0,0 +1,3 @@
+{if $__chat->isActiveApplication()}
+ {lang}chat.page.copyright{/lang}
+{/if}
diff --git a/templates/boxRoomList.tpl b/templates/boxRoomList.tpl
new file mode 100644
index 0000000..298a3dd
--- /dev/null
+++ b/templates/boxRoomList.tpl
@@ -0,0 +1,67 @@
+
+ {capture assign='chatBoxRoomList'}
+ {foreach from=$boxRoomList item='room'}
+ {if $room->canSee() && (!$skipEmptyRooms|isset || !$skipEmptyRooms || !$room->getUsers()|empty)}
+
roomID === $activeRoomID} class="active"{/if}>
+
+
+ {assign var='disallowJoinReason' value=null}
+
+ {if $room->canJoin(null, $disallowJoinReason)}
+
+ {$room->getTitle()}
+ {#$room->getUsers()|count}{if $room->userLimit} / {#$room->userLimit}{/if}
+
+
+ {if $room->getTopic()}
+
{@$room->getTopic()}
+ {/if}
+ {else}
+
{$room->getTitle()} {#$room->getUsers()|count}{if $room->userLimit} / {#$room->userLimit}{/if}
+ {/if}
+
+
+ {if !$room->getUsers()|empty || $disallowJoinReason !== null}
+
+ {if !$room->getUsers()|empty}
+
+ {/if}
+
+ {if $disallowJoinReason !== null}
+
{$disallowJoinReason->getMessage()}
+ {/if}
+
+ {/if}
+
+
+
+ {/if}
+ {/foreach}
+ {/capture}
+ {if $chatBoxRoomList|trim}
+
+ {@$chatBoxRoomList}
+
+ {else}
+
{lang}chat.box.noRooms{/lang}
+ {/if}
+
+
+
diff --git a/templates/boxRoomListSidebar.tpl b/templates/boxRoomListSidebar.tpl
new file mode 100644
index 0000000..2d831e1
--- /dev/null
+++ b/templates/boxRoomListSidebar.tpl
@@ -0,0 +1,44 @@
+
+ {capture assign='chatBoxRoomList'}
+ {foreach from=$boxRoomList item='room'}
+ {if $room->canSee() && (!$skipEmptyRooms|isset || !$skipEmptyRooms || !$room->getUsers()|empty)}
+
roomID === $activeRoomID} class="active"{/if}>
+ {if $room->canJoin()}
+
+ {else}
+
+ {/if}
+
+ {/if}
+ {/foreach}
+ {/capture}
+ {if $chatBoxRoomList|trim}
+
+ {else}
+
+ {/if}
+
+
+
diff --git a/templates/errorDialog.tpl b/templates/errorDialog.tpl
new file mode 100644
index 0000000..dfee1b1
--- /dev/null
+++ b/templates/errorDialog.tpl
@@ -0,0 +1,6 @@
+
diff --git a/templates/infoCommandSuspensions.tpl b/templates/infoCommandSuspensions.tpl
new file mode 100644
index 0000000..b40d693
--- /dev/null
+++ b/templates/infoCommandSuspensions.tpl
@@ -0,0 +1,38 @@
+{literal}
+{if $message.payload.suspensions && $__window.Object.keys($message.payload.suspensions).length > 0}
+
+
+
{lang}chat.messageType.be.bastelstu.chat.messageType.info.suspensions{/lang}
+
+
+
+
+
+
+ {lang}chat.suspension.type{/lang}
+ {lang}chat.suspension.judge{/lang}
+ {lang}chat.suspension.room{/lang}
+ {lang}chat.acp.suspension.time{/lang}
+ {lang}chat.acp.suspension.expires{/lang}
+ {event name='columnHeads'}
+
+
+
+
+ {foreach from=$message.payload.suspensions item="suspension"}
+
+ {lang}chat.suspension.type.{$suspension.objectType}{/lang}
+ {$suspension.judge}
+ {if $suspension.roomID !== null}{$suspension.room.title} {else}–{/if}
+ {@$suspension.timeElement}
+ {if $suspension.expires !== null}{@$suspension.expiresElement}{else}{lang}chat.acp.suspension.expires.forever{/lang}{/if}
+ {event name='columns'}
+
+ {/foreach}
+
+
+
+
+
+{/if}
+{/literal}
diff --git a/templates/infoCommandSuspensionsDecorator.tpl b/templates/infoCommandSuspensionsDecorator.tpl
new file mode 100644
index 0000000..5ea7389
--- /dev/null
+++ b/templates/infoCommandSuspensionsDecorator.tpl
@@ -0,0 +1,29 @@
+const infoCommandSuspensionDecorator = new Promise((resolve, reject) => {
+ require([ 'Bastelstu.be/Chat/Helper' ], Helper => {
+ chat.bottle.decorator('MessageType.be-bastelstu-chat-messageType-info', messageType => {
+ messageType.addDecorator(payload => {
+ if (payload.suspensions) {
+ payload.suspensions = payload.suspensions.map(suspension => {
+ suspension = Object.assign({ }, suspension)
+
+ suspension.timeElement = Helper.getTimeElementHTML(new Date(suspension.time * 1000))
+
+ if (suspension.expires) {
+ suspension.expiresElement = Helper.getTimeElementHTML(new Date(suspension.expires * 1000))
+ }
+
+ return suspension
+ })
+ }
+
+ return payload
+ })
+
+ return messageType
+ })
+
+ resolve()
+ }, reject)
+})
+
+promises.add(infoCommandSuspensionDecorator)
diff --git a/templates/log.tpl b/templates/log.tpl
new file mode 100644
index 0000000..2aac7b7
--- /dev/null
+++ b/templates/log.tpl
@@ -0,0 +1,104 @@
+{include file='header'}
+
+{capture assign='sidebarRight'}
+
+
+
+ {lang}wcf.acp.box.boxController.be.bastelstu.chat.roomList{/lang}
+
+
+
+
+
+{/capture}
+
+
+
+
+
+ {lang}chat.connection.warning{/lang}
+
+
+
+
+
+
+{include file='errorDialog' application='chat'}
+{include file='messageTypes' application='chat'}
+
+
+
+{include file='footer'}
diff --git a/templates/messageTypes.tpl b/templates/messageTypes.tpl
new file mode 100644
index 0000000..12f0ab2
--- /dev/null
+++ b/templates/messageTypes.tpl
@@ -0,0 +1,481 @@
+
+
+{literal}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{/literal}
+{event name='messageTypes'}
diff --git a/templates/quickSettings.tpl b/templates/quickSettings.tpl
new file mode 100644
index 0000000..83da260
--- /dev/null
+++ b/templates/quickSettings.tpl
@@ -0,0 +1,9 @@
+
+
+
diff --git a/templates/room.tpl b/templates/room.tpl
new file mode 100644
index 0000000..4c31354
--- /dev/null
+++ b/templates/room.tpl
@@ -0,0 +1,147 @@
+{assign var='pageTitle' value=$room->getTitle()}
+
+{capture assign='sidebarRight'}
+
+ {lang}chat.room.userList{/lang}
+
+
+
+{/capture}
+
+{capture assign='headerNavigation'}
+ {if $room->canSeeLog()}
+
+
+ {lang}chat.log.title{/lang}
+
+
+ {/if}
+
+
+ {lang}chat.room.button.leave{/lang}
+
+
+{/capture}
+
+{include file='header'}
+
+{if $room->getTopic()}
+
+
+ {@$room->getTopic()}
+
+{/if}
+
+
+
+
+ {lang}chat.connection.warning{/lang}
+
+
+ {lang}chat.stream.activity{/lang}
+
+
+
+
+
+
+
+
+{assign var=smileyCategories value=$__wcf->getSmileyCache()->getVisibleCategories()}
+{include file='quickSettings' application='chat'}
+{include file='smileyPicker' application='chat'}
+
+{include file='errorDialog' application='chat'}
+{include file='messageTypes' application='chat'}
+{include file='userList' application='chat'}
+{include file='userListDropdownMenuItems' application='chat'}
+
+{if !ENABLE_DEBUG_MODE}{js application='wcf' file='Bastelstu.be.Chat'}{/if}
+
+
+{include file='footer'}
diff --git a/templates/roomList.tpl b/templates/roomList.tpl
new file mode 100644
index 0000000..9a8262b
--- /dev/null
+++ b/templates/roomList.tpl
@@ -0,0 +1,9 @@
+{include file='header'}
+
+
+
+ {include application='chat' file='boxRoomList' boxRoomList=$rooms}
+
+
+
+{include file='footer'}
diff --git a/templates/smileyPicker.tpl b/templates/smileyPicker.tpl
new file mode 100644
index 0000000..f1d70e1
--- /dev/null
+++ b/templates/smileyPicker.tpl
@@ -0,0 +1,6 @@
+{if MODULE_SMILEY && !$smileyCategories|empty}
+
+ {include file='messageFormSmilies'}
+ {lang}wcf.global.button.close{/lang}
+
+{/if}
\ No newline at end of file
diff --git a/templates/temproomCommandLanguage.tpl b/templates/temproomCommandLanguage.tpl
new file mode 100644
index 0000000..8e66589
--- /dev/null
+++ b/templates/temproomCommandLanguage.tpl
@@ -0,0 +1,5 @@
+Language.addObject({
+ 'chat.messageType.be.bastelstu.chat.messageType.temproomCreated': '{lang __literal=true}chat.messageType.be.bastelstu.chat.messageType.temproomCreated{/lang}',
+ 'chat.messageType.be.bastelstu.chat.messageType.temproomInvited.invitee': '{lang __literal=true}chat.messageType.be.bastelstu.chat.messageType.temproomInvited.invitee{/lang}',
+ 'chat.messageType.be.bastelstu.chat.messageType.temproomInvited.invitor': '{lang __literal=true}chat.messageType.be.bastelstu.chat.messageType.temproomInvited.invitor{/lang}'
+})
diff --git a/templates/temproomCommandMessageTypes.tpl b/templates/temproomCommandMessageTypes.tpl
new file mode 100644
index 0000000..43f2f3b
--- /dev/null
+++ b/templates/temproomCommandMessageTypes.tpl
@@ -0,0 +1,33 @@
+{literal}
+
+
+
+{/literal}
diff --git a/templates/userList.tpl b/templates/userList.tpl
new file mode 100644
index 0000000..2a3c916
--- /dev/null
+++ b/templates/userList.tpl
@@ -0,0 +1,27 @@
+{literal}
+
+{/literal}
diff --git a/templates/userListDropdownMenuItems.tpl b/templates/userListDropdownMenuItems.tpl
new file mode 100644
index 0000000..e782816
--- /dev/null
+++ b/templates/userListDropdownMenuItems.tpl
@@ -0,0 +1,20 @@
+
diff --git a/templates/userListModerator.tpl b/templates/userListModerator.tpl
new file mode 100644
index 0000000..953009d
--- /dev/null
+++ b/templates/userListModerator.tpl
@@ -0,0 +1,3 @@
+{ldelim}if $user.permissions.canMute || $user.permissions.canBan}
+
+{ldelim}/if}
diff --git a/userGroupOption.xml b/userGroupOption.xml
new file mode 100644
index 0000000..78acaf8
--- /dev/null
+++ b/userGroupOption.xml
@@ -0,0 +1,144 @@
+
+
+
+
+
+ user
+
+
+ mod
+
+
+ admin.application
+
+
+
+
+
+ user.chat
+ boolean
+ 0
+ 1
+ 1
+
+
+ user.chat
+ boolean
+ 0
+ 1
+ 1
+
+
+ user.chat
+ boolean
+ 0
+ 1
+ 1
+
+
+ user.chat
+ boolean
+ 0
+ 1
+ 1
+
+
+ user.chat
+ boolean
+ 0
+ 1
+ 1
+
+
+ user.chat
+ BBCodeSelect
+ 1
+
+
+ mod.chat
+ boolean
+ 0
+ 1
+ 1
+ 1
+
+
+ mod.chat
+ boolean
+ 0
+ 1
+ 1
+ 1
+
+
+ mod.chat
+ boolean
+ 0
+ 1
+ 1
+ 1
+
+
+ mod.chat
+ boolean
+ 0
+ 1
+ 1
+ 1
+
+
+ mod.chat
+ boolean
+ 0
+ 1
+ 1
+ 1
+
+
+ mod.chat
+ boolean
+ 0
+ 1
+ 1
+ 1
+
+
+ mod.chat
+ boolean
+ 0
+ 1
+ 1
+ 1
+
+
+ mod.chat
+ boolean
+ 0
+ 1
+ 1
+ 1
+
+
+ admin.chat
+ boolean
+ 0
+ 1
+ 1
+
+
+ admin.chat
+ boolean
+ 0
+ 1
+ 1
+
+
+ admin.chat
+ boolean
+ 0
+ 1
+ 1
+
+
+
+
diff --git a/yarn.lock b/yarn.lock
new file mode 100644
index 0000000..d99ce05
--- /dev/null
+++ b/yarn.lock
@@ -0,0 +1,1397 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+abbrev@1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
+
+ansi-regex@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
+
+ansi-regex@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
+
+ansi-styles@^2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
+
+anymatch@^1.3.0:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a"
+ dependencies:
+ micromatch "^2.1.5"
+ normalize-path "^2.0.0"
+
+aproba@^1.0.3:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
+
+are-we-there-yet@~1.1.2:
+ version "1.1.5"
+ resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
+ dependencies:
+ delegates "^1.0.0"
+ readable-stream "^2.0.6"
+
+arr-diff@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf"
+ dependencies:
+ arr-flatten "^1.0.1"
+
+arr-flatten@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
+
+array-unique@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
+
+async-each@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d"
+
+babel-cli@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-cli/-/babel-cli-6.26.0.tgz#502ab54874d7db88ad00b887a06383ce03d002f1"
+ dependencies:
+ babel-core "^6.26.0"
+ babel-polyfill "^6.26.0"
+ babel-register "^6.26.0"
+ babel-runtime "^6.26.0"
+ commander "^2.11.0"
+ convert-source-map "^1.5.0"
+ fs-readdir-recursive "^1.0.0"
+ glob "^7.1.2"
+ lodash "^4.17.4"
+ output-file-sync "^1.1.2"
+ path-is-absolute "^1.0.1"
+ slash "^1.0.0"
+ source-map "^0.5.6"
+ v8flags "^2.1.1"
+ optionalDependencies:
+ chokidar "^1.6.1"
+
+babel-code-frame@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
+ dependencies:
+ chalk "^1.1.3"
+ esutils "^2.0.2"
+ js-tokens "^3.0.2"
+
+babel-core@^6.26.0:
+ version "6.26.3"
+ resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.3.tgz#b2e2f09e342d0f0c88e2f02e067794125e75c207"
+ dependencies:
+ babel-code-frame "^6.26.0"
+ babel-generator "^6.26.0"
+ babel-helpers "^6.24.1"
+ babel-messages "^6.23.0"
+ babel-register "^6.26.0"
+ babel-runtime "^6.26.0"
+ babel-template "^6.26.0"
+ babel-traverse "^6.26.0"
+ babel-types "^6.26.0"
+ babylon "^6.18.0"
+ convert-source-map "^1.5.1"
+ debug "^2.6.9"
+ json5 "^0.5.1"
+ lodash "^4.17.4"
+ minimatch "^3.0.4"
+ path-is-absolute "^1.0.1"
+ private "^0.1.8"
+ slash "^1.0.0"
+ source-map "^0.5.7"
+
+babel-generator@^6.26.0:
+ version "6.26.1"
+ resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90"
+ dependencies:
+ babel-messages "^6.23.0"
+ babel-runtime "^6.26.0"
+ babel-types "^6.26.0"
+ detect-indent "^4.0.0"
+ jsesc "^1.3.0"
+ lodash "^4.17.4"
+ source-map "^0.5.7"
+ trim-right "^1.0.1"
+
+babel-helper-builder-binary-assignment-operator-visitor@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz#cce4517ada356f4220bcae8a02c2b346f9a56664"
+ dependencies:
+ babel-helper-explode-assignable-expression "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-helper-call-delegate@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz#ece6aacddc76e41c3461f88bfc575bd0daa2df8d"
+ dependencies:
+ babel-helper-hoist-variables "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-helper-define-map@^6.24.1:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz#a5f56dab41a25f97ecb498c7ebaca9819f95be5f"
+ dependencies:
+ babel-helper-function-name "^6.24.1"
+ babel-runtime "^6.26.0"
+ babel-types "^6.26.0"
+ lodash "^4.17.4"
+
+babel-helper-explode-assignable-expression@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz#f25b82cf7dc10433c55f70592d5746400ac22caa"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-helper-function-name@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz#d3475b8c03ed98242a25b48351ab18399d3580a9"
+ dependencies:
+ babel-helper-get-function-arity "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-helper-get-function-arity@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz#8f7782aa93407c41d3aa50908f89b031b1b6853d"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-helper-hoist-variables@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz#1ecb27689c9d25513eadbc9914a73f5408be7a76"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-helper-optimise-call-expression@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz#f7a13427ba9f73f8f4fa993c54a97882d1244257"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-helper-regex@^6.24.1:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz#325c59f902f82f24b74faceed0363954f6495e72"
+ dependencies:
+ babel-runtime "^6.26.0"
+ babel-types "^6.26.0"
+ lodash "^4.17.4"
+
+babel-helper-remap-async-to-generator@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz#5ec581827ad723fecdd381f1c928390676e4551b"
+ dependencies:
+ babel-helper-function-name "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-helper-replace-supers@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz#bf6dbfe43938d17369a213ca8a8bf74b6a90ab1a"
+ dependencies:
+ babel-helper-optimise-call-expression "^6.24.1"
+ babel-messages "^6.23.0"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-helpers@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.24.1.tgz#3471de9caec388e5c850e597e58a26ddf37602b2"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+
+babel-messages@^6.23.0:
+ version "6.23.0"
+ resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-check-es2015-constants@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz#35157b101426fd2ffd3da3f75c7d1e91835bbf8a"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-syntax-async-functions@^6.8.0:
+ version "6.13.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95"
+
+babel-plugin-syntax-exponentiation-operator@^6.8.0:
+ version "6.13.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de"
+
+babel-plugin-syntax-trailing-function-commas@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz#ba0360937f8d06e40180a43fe0d5616fff532cf3"
+
+babel-plugin-transform-async-to-generator@^6.22.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz#6536e378aff6cb1d5517ac0e40eb3e9fc8d08761"
+ dependencies:
+ babel-helper-remap-async-to-generator "^6.24.1"
+ babel-plugin-syntax-async-functions "^6.8.0"
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-arrow-functions@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz#452692cb711d5f79dc7f85e440ce41b9f244d221"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-block-scoped-functions@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz#bbc51b49f964d70cb8d8e0b94e820246ce3a6141"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-block-scoping@^6.23.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz#d70f5299c1308d05c12f463813b0a09e73b1895f"
+ dependencies:
+ babel-runtime "^6.26.0"
+ babel-template "^6.26.0"
+ babel-traverse "^6.26.0"
+ babel-types "^6.26.0"
+ lodash "^4.17.4"
+
+babel-plugin-transform-es2015-classes@^6.23.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz#5a4c58a50c9c9461e564b4b2a3bfabc97a2584db"
+ dependencies:
+ babel-helper-define-map "^6.24.1"
+ babel-helper-function-name "^6.24.1"
+ babel-helper-optimise-call-expression "^6.24.1"
+ babel-helper-replace-supers "^6.24.1"
+ babel-messages "^6.23.0"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-computed-properties@^6.22.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz#6fe2a8d16895d5634f4cd999b6d3480a308159b3"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+
+babel-plugin-transform-es2015-destructuring@^6.23.0:
+ version "6.23.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz#997bb1f1ab967f682d2b0876fe358d60e765c56d"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-duplicate-keys@^6.22.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz#73eb3d310ca969e3ef9ec91c53741a6f1576423e"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-for-of@^6.23.0:
+ version "6.23.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz#f47c95b2b613df1d3ecc2fdb7573623c75248691"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-function-name@^6.22.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz#834c89853bc36b1af0f3a4c5dbaa94fd8eacaa8b"
+ dependencies:
+ babel-helper-function-name "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-literals@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz#4f54a02d6cd66cf915280019a31d31925377ca2e"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-modules-amd@^6.22.0, babel-plugin-transform-es2015-modules-amd@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz#3b3e54017239842d6d19c3011c4bd2f00a00d154"
+ dependencies:
+ babel-plugin-transform-es2015-modules-commonjs "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+
+babel-plugin-transform-es2015-modules-commonjs@^6.23.0, babel-plugin-transform-es2015-modules-commonjs@^6.24.1:
+ version "6.26.2"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.2.tgz#58a793863a9e7ca870bdc5a881117ffac27db6f3"
+ dependencies:
+ babel-plugin-transform-strict-mode "^6.24.1"
+ babel-runtime "^6.26.0"
+ babel-template "^6.26.0"
+ babel-types "^6.26.0"
+
+babel-plugin-transform-es2015-modules-systemjs@^6.23.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz#ff89a142b9119a906195f5f106ecf305d9407d23"
+ dependencies:
+ babel-helper-hoist-variables "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+
+babel-plugin-transform-es2015-modules-umd@^6.23.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz#ac997e6285cd18ed6176adb607d602344ad38468"
+ dependencies:
+ babel-plugin-transform-es2015-modules-amd "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+
+babel-plugin-transform-es2015-object-super@^6.22.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz#24cef69ae21cb83a7f8603dad021f572eb278f8d"
+ dependencies:
+ babel-helper-replace-supers "^6.24.1"
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-parameters@^6.23.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz#57ac351ab49caf14a97cd13b09f66fdf0a625f2b"
+ dependencies:
+ babel-helper-call-delegate "^6.24.1"
+ babel-helper-get-function-arity "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-template "^6.24.1"
+ babel-traverse "^6.24.1"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-shorthand-properties@^6.22.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz#24f875d6721c87661bbd99a4622e51f14de38aa0"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-spread@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz#d6d68a99f89aedc4536c81a542e8dd9f1746f8d1"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-sticky-regex@^6.22.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz#00c1cdb1aca71112cdf0cf6126c2ed6b457ccdbc"
+ dependencies:
+ babel-helper-regex "^6.24.1"
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-plugin-transform-es2015-template-literals@^6.22.0:
+ version "6.22.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz#a84b3450f7e9f8f1f6839d6d687da84bb1236d8d"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-typeof-symbol@^6.23.0:
+ version "6.23.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz#dec09f1cddff94b52ac73d505c84df59dcceb372"
+ dependencies:
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-es2015-unicode-regex@^6.22.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz#d38b12f42ea7323f729387f18a7c5ae1faeb35e9"
+ dependencies:
+ babel-helper-regex "^6.24.1"
+ babel-runtime "^6.22.0"
+ regexpu-core "^2.0.0"
+
+babel-plugin-transform-exponentiation-operator@^6.22.0:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz#2ab0c9c7f3098fa48907772bb813fe41e8de3a0e"
+ dependencies:
+ babel-helper-builder-binary-assignment-operator-visitor "^6.24.1"
+ babel-plugin-syntax-exponentiation-operator "^6.8.0"
+ babel-runtime "^6.22.0"
+
+babel-plugin-transform-regenerator@^6.22.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz#e0703696fbde27f0a3efcacf8b4dca2f7b3a8f2f"
+ dependencies:
+ regenerator-transform "^0.10.0"
+
+babel-plugin-transform-strict-mode@^6.24.1:
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz#d5faf7aa578a65bbe591cf5edae04a0c67020758"
+ dependencies:
+ babel-runtime "^6.22.0"
+ babel-types "^6.24.1"
+
+babel-polyfill@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.26.0.tgz#379937abc67d7895970adc621f284cd966cf2153"
+ dependencies:
+ babel-runtime "^6.26.0"
+ core-js "^2.5.0"
+ regenerator-runtime "^0.10.5"
+
+babel-preset-env@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/babel-preset-env/-/babel-preset-env-1.7.0.tgz#dea79fa4ebeb883cd35dab07e260c1c9c04df77a"
+ dependencies:
+ babel-plugin-check-es2015-constants "^6.22.0"
+ babel-plugin-syntax-trailing-function-commas "^6.22.0"
+ babel-plugin-transform-async-to-generator "^6.22.0"
+ babel-plugin-transform-es2015-arrow-functions "^6.22.0"
+ babel-plugin-transform-es2015-block-scoped-functions "^6.22.0"
+ babel-plugin-transform-es2015-block-scoping "^6.23.0"
+ babel-plugin-transform-es2015-classes "^6.23.0"
+ babel-plugin-transform-es2015-computed-properties "^6.22.0"
+ babel-plugin-transform-es2015-destructuring "^6.23.0"
+ babel-plugin-transform-es2015-duplicate-keys "^6.22.0"
+ babel-plugin-transform-es2015-for-of "^6.23.0"
+ babel-plugin-transform-es2015-function-name "^6.22.0"
+ babel-plugin-transform-es2015-literals "^6.22.0"
+ babel-plugin-transform-es2015-modules-amd "^6.22.0"
+ babel-plugin-transform-es2015-modules-commonjs "^6.23.0"
+ babel-plugin-transform-es2015-modules-systemjs "^6.23.0"
+ babel-plugin-transform-es2015-modules-umd "^6.23.0"
+ babel-plugin-transform-es2015-object-super "^6.22.0"
+ babel-plugin-transform-es2015-parameters "^6.23.0"
+ babel-plugin-transform-es2015-shorthand-properties "^6.22.0"
+ babel-plugin-transform-es2015-spread "^6.22.0"
+ babel-plugin-transform-es2015-sticky-regex "^6.22.0"
+ babel-plugin-transform-es2015-template-literals "^6.22.0"
+ babel-plugin-transform-es2015-typeof-symbol "^6.23.0"
+ babel-plugin-transform-es2015-unicode-regex "^6.22.0"
+ babel-plugin-transform-exponentiation-operator "^6.22.0"
+ babel-plugin-transform-regenerator "^6.22.0"
+ browserslist "^3.2.6"
+ invariant "^2.2.2"
+ semver "^5.3.0"
+
+babel-register@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.26.0.tgz#6ed021173e2fcb486d7acb45c6009a856f647071"
+ dependencies:
+ babel-core "^6.26.0"
+ babel-runtime "^6.26.0"
+ core-js "^2.5.0"
+ home-or-tmp "^2.0.0"
+ lodash "^4.17.4"
+ mkdirp "^0.5.1"
+ source-map-support "^0.4.15"
+
+babel-runtime@^6.18.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
+ dependencies:
+ core-js "^2.4.0"
+ regenerator-runtime "^0.11.0"
+
+babel-template@^6.24.1, babel-template@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.26.0.tgz#de03e2d16396b069f46dd9fff8521fb1a0e35e02"
+ dependencies:
+ babel-runtime "^6.26.0"
+ babel-traverse "^6.26.0"
+ babel-types "^6.26.0"
+ babylon "^6.18.0"
+ lodash "^4.17.4"
+
+babel-traverse@^6.24.1, babel-traverse@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
+ dependencies:
+ babel-code-frame "^6.26.0"
+ babel-messages "^6.23.0"
+ babel-runtime "^6.26.0"
+ babel-types "^6.26.0"
+ babylon "^6.18.0"
+ debug "^2.6.8"
+ globals "^9.18.0"
+ invariant "^2.2.2"
+ lodash "^4.17.4"
+
+babel-types@^6.19.0, babel-types@^6.24.1, babel-types@^6.26.0:
+ version "6.26.0"
+ resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
+ dependencies:
+ babel-runtime "^6.26.0"
+ esutils "^2.0.2"
+ lodash "^4.17.4"
+ to-fast-properties "^1.0.3"
+
+babylon@^6.18.0:
+ version "6.18.0"
+ resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
+
+balanced-match@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
+
+binary-extensions@^1.0.0:
+ version "1.11.0"
+ resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205"
+
+brace-expansion@^1.1.7:
+ version "1.1.11"
+ resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+ dependencies:
+ balanced-match "^1.0.0"
+ concat-map "0.0.1"
+
+braces@^1.8.2:
+ version "1.8.5"
+ resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7"
+ dependencies:
+ expand-range "^1.8.1"
+ preserve "^0.2.0"
+ repeat-element "^1.1.2"
+
+browserslist@^3.2.6:
+ version "3.2.8"
+ resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-3.2.8.tgz#b0005361d6471f0f5952797a76fc985f1f978fc6"
+ dependencies:
+ caniuse-lite "^1.0.30000844"
+ electron-to-chromium "^1.3.47"
+
+buffer-from@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.0.tgz#87fcaa3a298358e0ade6e442cfce840740d1ad04"
+
+caniuse-lite@^1.0.30000844:
+ version "1.0.30000865"
+ resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000865.tgz#70026616e8afe6e1442f8bb4e1092987d81a2f25"
+
+chalk@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
+ dependencies:
+ ansi-styles "^2.2.1"
+ escape-string-regexp "^1.0.2"
+ has-ansi "^2.0.0"
+ strip-ansi "^3.0.0"
+ supports-color "^2.0.0"
+
+chokidar@^1.6.1:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
+ dependencies:
+ anymatch "^1.3.0"
+ async-each "^1.0.0"
+ glob-parent "^2.0.0"
+ inherits "^2.0.1"
+ is-binary-path "^1.0.0"
+ is-glob "^2.0.0"
+ path-is-absolute "^1.0.0"
+ readdirp "^2.0.0"
+ optionalDependencies:
+ fsevents "^1.0.0"
+
+chownr@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181"
+
+code-point-at@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+
+commander@^2.11.0, commander@~2.16.0:
+ version "2.16.0"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-2.16.0.tgz#f16390593996ceb4f3eeb020b31d78528f7f8a50"
+
+concat-map@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+
+console-control-strings@^1.0.0, console-control-strings@~1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
+
+convert-source-map@^1.5.0, convert-source-map@^1.5.1:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.1.tgz#b8278097b9bc229365de5c62cf5fcaed8b5599e5"
+
+core-js@^2.4.0, core-js@^2.5.0:
+ version "2.5.7"
+ resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e"
+
+core-util-is@~1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
+
+debug@^2.1.2, debug@^2.6.8, debug@^2.6.9:
+ version "2.6.9"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+ dependencies:
+ ms "2.0.0"
+
+deep-extend@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+
+delegates@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+
+detect-indent@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
+ dependencies:
+ repeating "^2.0.0"
+
+detect-libc@^1.0.2:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
+
+electron-to-chromium@^1.3.47:
+ version "1.3.52"
+ resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.52.tgz#d2d9f1270ba4a3b967b831c40ef71fb4d9ab5ce0"
+
+escape-string-regexp@^1.0.2:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+
+esutils@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
+
+expand-brackets@^0.1.4:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
+ dependencies:
+ is-posix-bracket "^0.1.0"
+
+expand-range@^1.8.1:
+ version "1.8.2"
+ resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337"
+ dependencies:
+ fill-range "^2.1.0"
+
+extglob@^0.3.1:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
+ dependencies:
+ is-extglob "^1.0.0"
+
+filename-regex@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
+
+fill-range@^2.1.0:
+ version "2.2.4"
+ resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565"
+ dependencies:
+ is-number "^2.1.0"
+ isobject "^2.0.0"
+ randomatic "^3.0.0"
+ repeat-element "^1.1.2"
+ repeat-string "^1.5.2"
+
+for-in@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
+
+for-own@^0.1.4:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
+ dependencies:
+ for-in "^1.0.1"
+
+fs-minipass@^1.2.5:
+ version "1.2.5"
+ resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d"
+ dependencies:
+ minipass "^2.2.1"
+
+fs-readdir-recursive@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz#e32fc030a2ccee44a6b5371308da54be0b397d27"
+
+fs.realpath@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+
+fsevents@^1.0.0:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.4.tgz#f41dcb1af2582af3692da36fc55cbd8e1041c426"
+ dependencies:
+ nan "^2.9.2"
+ node-pre-gyp "^0.10.0"
+
+gauge@~2.7.3:
+ version "2.7.4"
+ resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
+ dependencies:
+ aproba "^1.0.3"
+ console-control-strings "^1.0.0"
+ has-unicode "^2.0.0"
+ object-assign "^4.1.0"
+ signal-exit "^3.0.0"
+ string-width "^1.0.1"
+ strip-ansi "^3.0.1"
+ wide-align "^1.1.0"
+
+glob-base@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
+ dependencies:
+ glob-parent "^2.0.0"
+ is-glob "^2.0.0"
+
+glob-parent@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28"
+ dependencies:
+ is-glob "^2.0.0"
+
+glob@^7.0.5, glob@^7.1.2:
+ version "7.1.2"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"
+ dependencies:
+ fs.realpath "^1.0.0"
+ inflight "^1.0.4"
+ inherits "2"
+ minimatch "^3.0.4"
+ once "^1.3.0"
+ path-is-absolute "^1.0.0"
+
+globals@^9.18.0:
+ version "9.18.0"
+ resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
+
+graceful-fs@^4.1.2, graceful-fs@^4.1.4:
+ version "4.1.11"
+ resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
+
+has-ansi@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
+ dependencies:
+ ansi-regex "^2.0.0"
+
+has-unicode@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
+
+home-or-tmp@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
+ dependencies:
+ os-homedir "^1.0.0"
+ os-tmpdir "^1.0.1"
+
+iconv-lite@^0.4.4:
+ version "0.4.23"
+ resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
+ dependencies:
+ safer-buffer ">= 2.1.2 < 3"
+
+ignore-walk@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8"
+ dependencies:
+ minimatch "^3.0.4"
+
+inflight@^1.0.4:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+ dependencies:
+ once "^1.3.0"
+ wrappy "1"
+
+inherits@2, inherits@^2.0.1, inherits@~2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+
+ini@~1.3.0:
+ version "1.3.5"
+ resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
+
+invariant@^2.2.2:
+ version "2.2.4"
+ resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
+ dependencies:
+ loose-envify "^1.0.0"
+
+is-binary-path@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898"
+ dependencies:
+ binary-extensions "^1.0.0"
+
+is-buffer@^1.1.5:
+ version "1.1.6"
+ resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
+
+is-dotfile@^1.0.0:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
+
+is-equal-shallow@^0.1.3:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
+ dependencies:
+ is-primitive "^2.0.0"
+
+is-extendable@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
+
+is-extglob@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
+
+is-finite@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa"
+ dependencies:
+ number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
+ dependencies:
+ number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
+
+is-glob@^2.0.0, is-glob@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
+ dependencies:
+ is-extglob "^1.0.0"
+
+is-number@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
+ dependencies:
+ kind-of "^3.0.2"
+
+is-number@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
+
+is-posix-bracket@^0.1.0:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
+
+is-primitive@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
+
+isarray@1.0.0, isarray@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+
+isobject@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
+ dependencies:
+ isarray "1.0.0"
+
+"js-tokens@^3.0.0 || ^4.0.0":
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+
+js-tokens@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
+
+jsesc@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
+
+jsesc@~0.5.0:
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
+
+json5@^0.5.1:
+ version "0.5.1"
+ resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821"
+
+kind-of@^3.0.2:
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
+ dependencies:
+ is-buffer "^1.1.5"
+
+kind-of@^6.0.0:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051"
+
+lodash@^4.17.4:
+ version "4.17.10"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7"
+
+loose-envify@^1.0.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
+ dependencies:
+ js-tokens "^3.0.0 || ^4.0.0"
+
+math-random@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.1.tgz#8b3aac588b8a66e4975e3cdea67f7bb329601fac"
+
+micromatch@^2.1.5:
+ version "2.3.11"
+ resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565"
+ dependencies:
+ arr-diff "^2.0.0"
+ array-unique "^0.2.1"
+ braces "^1.8.2"
+ expand-brackets "^0.1.4"
+ extglob "^0.3.1"
+ filename-regex "^2.0.0"
+ is-extglob "^1.0.0"
+ is-glob "^2.0.1"
+ kind-of "^3.0.2"
+ normalize-path "^2.0.1"
+ object.omit "^2.0.0"
+ parse-glob "^3.0.4"
+ regex-cache "^0.4.2"
+
+minimatch@^3.0.2, minimatch@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+ dependencies:
+ brace-expansion "^1.1.7"
+
+minimist@0.0.8:
+ version "0.0.8"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
+
+minimist@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
+
+minipass@^2.2.1, minipass@^2.3.3:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.3.tgz#a7dcc8b7b833f5d368759cce544dccb55f50f233"
+ dependencies:
+ safe-buffer "^5.1.2"
+ yallist "^3.0.0"
+
+minizlib@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.0.tgz#11e13658ce46bc3a70a267aac58359d1e0c29ceb"
+ dependencies:
+ minipass "^2.2.1"
+
+mkdirp@^0.5.0, mkdirp@^0.5.1:
+ version "0.5.1"
+ resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
+ dependencies:
+ minimist "0.0.8"
+
+ms@2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+
+nan@^2.9.2:
+ version "2.10.0"
+ resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f"
+
+needle@^2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.1.tgz#b5e325bd3aae8c2678902fa296f729455d1d3a7d"
+ dependencies:
+ debug "^2.1.2"
+ iconv-lite "^0.4.4"
+ sax "^1.2.4"
+
+node-pre-gyp@^0.10.0:
+ version "0.10.3"
+ resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc"
+ dependencies:
+ detect-libc "^1.0.2"
+ mkdirp "^0.5.1"
+ needle "^2.2.1"
+ nopt "^4.0.1"
+ npm-packlist "^1.1.6"
+ npmlog "^4.0.2"
+ rc "^1.2.7"
+ rimraf "^2.6.1"
+ semver "^5.3.0"
+ tar "^4"
+
+nopt@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d"
+ dependencies:
+ abbrev "1"
+ osenv "^0.1.4"
+
+normalize-path@^2.0.0, normalize-path@^2.0.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
+ dependencies:
+ remove-trailing-separator "^1.0.1"
+
+npm-bundled@^1.0.1:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.3.tgz#7e71703d973af3370a9591bafe3a63aca0be2308"
+
+npm-packlist@^1.1.6:
+ version "1.1.11"
+ resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.11.tgz#84e8c683cbe7867d34b1d357d893ce29e28a02de"
+ dependencies:
+ ignore-walk "^3.0.1"
+ npm-bundled "^1.0.1"
+
+npmlog@^4.0.2:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
+ dependencies:
+ are-we-there-yet "~1.1.2"
+ console-control-strings "~1.1.0"
+ gauge "~2.7.3"
+ set-blocking "~2.0.0"
+
+number-is-nan@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
+
+object-assign@^4.1.0:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+
+object.omit@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
+ dependencies:
+ for-own "^0.1.4"
+ is-extendable "^0.1.1"
+
+once@^1.3.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+ dependencies:
+ wrappy "1"
+
+os-homedir@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
+
+os-tmpdir@^1.0.0, os-tmpdir@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
+
+osenv@^0.1.4:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
+ dependencies:
+ os-homedir "^1.0.0"
+ os-tmpdir "^1.0.0"
+
+output-file-sync@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/output-file-sync/-/output-file-sync-1.1.2.tgz#d0a33eefe61a205facb90092e826598d5245ce76"
+ dependencies:
+ graceful-fs "^4.1.4"
+ mkdirp "^0.5.1"
+ object-assign "^4.1.0"
+
+parse-glob@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
+ dependencies:
+ glob-base "^0.3.0"
+ is-dotfile "^1.0.0"
+ is-extglob "^1.0.0"
+ is-glob "^2.0.0"
+
+path-is-absolute@^1.0.0, path-is-absolute@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+
+preserve@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
+
+private@^0.1.6, private@^0.1.8:
+ version "0.1.8"
+ resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
+
+process-nextick-args@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa"
+
+randomatic@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.0.0.tgz#d35490030eb4f7578de292ce6dfb04a91a128923"
+ dependencies:
+ is-number "^4.0.0"
+ kind-of "^6.0.0"
+ math-random "^1.0.1"
+
+rc@^1.2.7:
+ version "1.2.8"
+ resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
+ dependencies:
+ deep-extend "^0.6.0"
+ ini "~1.3.0"
+ minimist "^1.2.0"
+ strip-json-comments "~2.0.1"
+
+readable-stream@^2.0.2, readable-stream@^2.0.6:
+ version "2.3.6"
+ resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
+ dependencies:
+ core-util-is "~1.0.0"
+ inherits "~2.0.3"
+ isarray "~1.0.0"
+ process-nextick-args "~2.0.0"
+ safe-buffer "~5.1.1"
+ string_decoder "~1.1.1"
+ util-deprecate "~1.0.1"
+
+readdirp@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78"
+ dependencies:
+ graceful-fs "^4.1.2"
+ minimatch "^3.0.2"
+ readable-stream "^2.0.2"
+ set-immediate-shim "^1.0.1"
+
+regenerate@^1.2.1:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11"
+
+regenerator-runtime@^0.10.5:
+ version "0.10.5"
+ resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz#336c3efc1220adcedda2c9fab67b5a7955a33658"
+
+regenerator-runtime@^0.11.0:
+ version "0.11.1"
+ resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
+
+regenerator-transform@^0.10.0:
+ version "0.10.1"
+ resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.10.1.tgz#1e4996837231da8b7f3cf4114d71b5691a0680dd"
+ dependencies:
+ babel-runtime "^6.18.0"
+ babel-types "^6.19.0"
+ private "^0.1.6"
+
+regex-cache@^0.4.2:
+ version "0.4.4"
+ resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"
+ dependencies:
+ is-equal-shallow "^0.1.3"
+
+regexpu-core@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-2.0.0.tgz#49d038837b8dcf8bfa5b9a42139938e6ea2ae240"
+ dependencies:
+ regenerate "^1.2.1"
+ regjsgen "^0.2.0"
+ regjsparser "^0.1.4"
+
+regjsgen@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7"
+
+regjsparser@^0.1.4:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c"
+ dependencies:
+ jsesc "~0.5.0"
+
+remove-trailing-separator@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
+
+repeat-element@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.2.tgz#ef089a178d1483baae4d93eb98b4f9e4e11d990a"
+
+repeat-string@^1.5.2:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
+
+repeating@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
+ dependencies:
+ is-finite "^1.0.0"
+
+requirejs@^2.3.5:
+ version "2.3.5"
+ resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.5.tgz#617b9acbbcb336540ef4914d790323a8d4b861b0"
+
+rimraf@^2.6.1:
+ version "2.6.2"
+ resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36"
+ dependencies:
+ glob "^7.0.5"
+
+safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+
+"safer-buffer@>= 2.1.2 < 3":
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+
+sax@^1.2.4:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
+
+semver@^5.3.0:
+ version "5.5.0"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab"
+
+set-blocking@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
+
+set-immediate-shim@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61"
+
+signal-exit@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
+
+slash@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
+
+source-map-support@^0.4.15:
+ version "0.4.18"
+ resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f"
+ dependencies:
+ source-map "^0.5.6"
+
+source-map-support@~0.5.6:
+ version "0.5.6"
+ resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.6.tgz#4435cee46b1aab62b8e8610ce60f788091c51c13"
+ dependencies:
+ buffer-from "^1.0.0"
+ source-map "^0.6.0"
+
+source-map@^0.5.6, source-map@^0.5.7:
+ version "0.5.7"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
+
+source-map@^0.6.0, source-map@~0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+
+string-width@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
+ dependencies:
+ code-point-at "^1.0.0"
+ is-fullwidth-code-point "^1.0.0"
+ strip-ansi "^3.0.0"
+
+"string-width@^1.0.2 || 2":
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
+ dependencies:
+ is-fullwidth-code-point "^2.0.0"
+ strip-ansi "^4.0.0"
+
+string_decoder@~1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
+ dependencies:
+ safe-buffer "~5.1.0"
+
+strip-ansi@^3.0.0, strip-ansi@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
+ dependencies:
+ ansi-regex "^2.0.0"
+
+strip-ansi@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
+ dependencies:
+ ansi-regex "^3.0.0"
+
+strip-json-comments@~2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
+
+supports-color@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
+
+tar@^4:
+ version "4.4.4"
+ resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.4.tgz#ec8409fae9f665a4355cc3b4087d0820232bb8cd"
+ dependencies:
+ chownr "^1.0.1"
+ fs-minipass "^1.2.5"
+ minipass "^2.3.3"
+ minizlib "^1.1.0"
+ mkdirp "^0.5.0"
+ safe-buffer "^5.1.2"
+ yallist "^3.0.2"
+
+terser@^3.8.1:
+ version "3.8.1"
+ resolved "https://registry.yarnpkg.com/terser/-/terser-3.8.1.tgz#cb70070ac9e0a71add169dfb63c0a64fca2738ac"
+ dependencies:
+ commander "~2.16.0"
+ source-map "~0.6.1"
+ source-map-support "~0.5.6"
+
+to-fast-properties@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
+
+trim-right@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
+
+user-home@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190"
+
+util-deprecate@~1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+
+v8flags@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.1.1.tgz#aab1a1fa30d45f88dd321148875ac02c0b55e5b4"
+ dependencies:
+ user-home "^1.1.1"
+
+wide-align@^1.1.0:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
+ dependencies:
+ string-width "^1.0.2 || 2"
+
+wrappy@1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+
+yallist@^3.0.0, yallist@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.2.tgz#8452b4bb7e83c7c188d8041c1a837c773d6d8bb9"