Browse Source

Add user-status app

Signed-off-by: Georg Ehrke <developer@georgehrke.com>
Georg Ehrke 3 years ago
parent
commit
0fad921840
83 changed files with 6678 additions and 6 deletions
  1. 1 0
      .gitignore
  2. 1 0
      COPYING-README
  3. 2 0
      Makefile
  4. 31 0
      apps/user_status/appinfo/info.xml
  5. 43 0
      apps/user_status/appinfo/routes.php
  6. 7 0
      apps/user_status/composer/autoload.php
  7. 13 0
      apps/user_status/composer/composer.json
  8. 445 0
      apps/user_status/composer/composer/ClassLoader.php
  9. 21 0
      apps/user_status/composer/composer/LICENSE
  10. 31 0
      apps/user_status/composer/composer/autoload_classmap.php
  11. 9 0
      apps/user_status/composer/composer/autoload_namespaces.php
  12. 10 0
      apps/user_status/composer/composer/autoload_psr4.php
  13. 46 0
      apps/user_status/composer/composer/autoload_real.php
  14. 57 0
      apps/user_status/composer/composer/autoload_static.php
  15. 37 0
      apps/user_status/css/user-status-menu.scss
  16. 1 0
      apps/user_status/img/app.svg
  17. 1 0
      apps/user_status/img/user-status-away.svg
  18. 1 0
      apps/user_status/img/user-status-dnd.svg
  19. 1 0
      apps/user_status/img/user-status-invisible.svg
  20. 1 0
      apps/user_status/img/user-status-online.svg
  21. 0 0
      apps/user_status/js/user-status-menu.js
  22. 0 0
      apps/user_status/js/user-status-menu.js.map
  23. 74 0
      apps/user_status/lib/AppInfo/Application.php
  24. 63 0
      apps/user_status/lib/BackgroundJob/ClearOldStatusesBackgroundJob.php
  25. 60 0
      apps/user_status/lib/Capabilities.php
  26. 92 0
      apps/user_status/lib/Controller/HeartbeatController.php
  27. 65 0
      apps/user_status/lib/Controller/PredefinedStatusController.php
  28. 107 0
      apps/user_status/lib/Controller/StatusesController.php
  29. 191 0
      apps/user_status/lib/Controller/UserStatusController.php
  30. 90 0
      apps/user_status/lib/Db/UserStatus.php
  31. 104 0
      apps/user_status/lib/Db/UserStatusMapper.php
  32. 29 0
      apps/user_status/lib/Exception/InvalidClearAtException.php
  33. 29 0
      apps/user_status/lib/Exception/InvalidMessageIdException.php
  34. 29 0
      apps/user_status/lib/Exception/InvalidStatusIconException.php
  35. 29 0
      apps/user_status/lib/Exception/InvalidStatusTypeException.php
  36. 29 0
      apps/user_status/lib/Exception/StatusMessageTooLongException.php
  37. 75 0
      apps/user_status/lib/Listener/BeforeTemplateRenderedListener.php
  38. 65 0
      apps/user_status/lib/Listener/UserDeletedListener.php
  39. 133 0
      apps/user_status/lib/Listener/UserLiveStatusListener.php
  40. 97 0
      apps/user_status/lib/Migration/Version0001Date20200602134824.php
  41. 100 0
      apps/user_status/lib/Service/EmojiService.php
  42. 84 0
      apps/user_status/lib/Service/JSDataService.php
  43. 187 0
      apps/user_status/lib/Service/PredefinedStatusService.php
  44. 335 0
      apps/user_status/lib/Service/StatusService.php
  45. 271 0
      apps/user_status/src/App.vue
  46. 102 0
      apps/user_status/src/components/ClearAtSelect.vue
  47. 65 0
      apps/user_status/src/components/CustomMessageInput.vue
  48. 111 0
      apps/user_status/src/components/PredefinedStatus.vue
  49. 90 0
      apps/user_status/src/components/PredefinedStatusesList.vue
  50. 236 0
      apps/user_status/src/components/SetStatusModal.vue
  51. 68 0
      apps/user_status/src/filters/clearAtFilter.js
  52. 23 0
      apps/user_status/src/main-user-status-menu.js
  53. 68 0
      apps/user_status/src/services/clearAtOptionsService.js
  54. 63 0
      apps/user_status/src/services/clearAtService.js
  55. 34 0
      apps/user_status/src/services/dateService.js
  56. 40 0
      apps/user_status/src/services/heartbeatService.js
  57. 39 0
      apps/user_status/src/services/predefinedStatusService.js
  58. 52 0
      apps/user_status/src/services/statusOptionsService.js
  59. 98 0
      apps/user_status/src/services/statusService.js
  60. 35 0
      apps/user_status/src/store/index.js
  61. 64 0
      apps/user_status/src/store/predefinedStatuses.js
  62. 232 0
      apps/user_status/src/store/userStatus.js
  63. 63 0
      apps/user_status/tests/Unit/BackgroundJob/ClearOldStatusesBackgroundJobTest.php
  64. 71 0
      apps/user_status/tests/Unit/CapabilitiesTest.php
  65. 74 0
      apps/user_status/tests/Unit/Controller/PredefinedStatusControllerTest.php
  66. 114 0
      apps/user_status/tests/Unit/Controller/StatusesControllerTest.php
  67. 340 0
      apps/user_status/tests/Unit/Controller/UserStatusControllerTest.php
  68. 168 0
      apps/user_status/tests/Unit/Db/UserStatusMapperTest.php
  69. 71 0
      apps/user_status/tests/Unit/Listener/UserDeletedListenerTest.php
  70. 162 0
      apps/user_status/tests/Unit/Listener/UserLiveStatusListenerTest.php
  71. 100 0
      apps/user_status/tests/Unit/Service/EmojiServiceTest.php
  72. 184 0
      apps/user_status/tests/Unit/Service/PredefinedStatusServiceTest.php
  73. 592 0
      apps/user_status/tests/Unit/Service/StatusServiceTest.php
  74. 36 0
      apps/user_status/tests/bootstrap.php
  75. 18 0
      apps/user_status/webpack.js
  76. 1 0
      lib/composer/composer/autoload_classmap.php
  77. 1 0
      lib/composer/composer/autoload_static.php
  78. 4 4
      lib/private/NavigationManager.php
  79. 101 0
      lib/public/User/Events/UserLiveStatusEvent.php
  80. 56 0
      package-lock.json
  81. 1 0
      package.json
  82. 2 2
      tests/lib/NavigationManagerTest.php
  83. 2 0
      webpack.common.js

+ 1 - 0
.gitignore

@@ -39,6 +39,7 @@
 !/apps/updatenotification
 !/apps/theming
 !/apps/twofactor_backupcodes
+!/apps/user_status
 !/apps/workflowengine
 /apps/files_external/3rdparty/irodsphp/PHPUnitTest
 /apps/files_external/3rdparty/irodsphp/web

+ 1 - 0
COPYING-README

@@ -9,6 +9,7 @@ Licensing of components:
 * User: AGPL
 * XML/RPC: MIT / PHP
 * Elementary filetype icons: GPL v3+
+* Material UI icons: APACHE LICENSE, VERSION 2.0
 All unmodified files from these and other sources retain their original copyright
 and license notices: see the relevant individual files.
 

+ 2 - 0
Makefile

@@ -39,6 +39,7 @@ clean:
 	rm -rf apps/systemtags/js/systemtags.*
 	rm -rf apps/twofactor_backupcodes/js
 	rm -rf apps/updatenotification/js/updatenotification.*
+	rm -rf apps/user_status/js/
 	rm -rf apps/workflowengine/js/
 	rm -rf core/js/dist
 
@@ -57,5 +58,6 @@ clean-git: clean
 	git checkout -- apps/systemtags/js/systemtags.*
 	git checkout -- apps/twofactor_backupcodes/js
 	git checkout -- apps/updatenotification/js/updatenotification.*
+	git checkout -- apps/user_status/js/
 	git checkout -- apps/workflowengine/js/
 	git checkout -- core/js/dist

+ 31 - 0
apps/user_status/appinfo/info.xml

@@ -0,0 +1,31 @@
+<?xml version="1.0"?>
+<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
+      xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
+    <id>user_status</id>
+    <name>User status</name>
+    <summary>User status</summary>
+    <description><![CDATA[User status]]></description>
+    <version>0.0.2</version>
+    <licence>agpl</licence>
+    <author mail="oc.list@georgehrke.com" >Georg Ehrke</author>
+    <namespace>UserStatus</namespace>
+    <default_enable/>
+    <category>social</category>
+    <bugs>https://github.com/nextcloud/server</bugs>
+    <navigations>
+        <navigation>
+            <id>user_status-menuitem</id>
+            <name>User status</name>
+            <route />
+            <order>1</order>
+            <icon>info.svg</icon>
+            <type>settings</type>
+        </navigation>
+    </navigations>
+    <dependencies>
+        <nextcloud min-version="20" max-version="20"/>
+    </dependencies>
+    <background-jobs>
+        <job>OCA\UserStatus\BackgroundJob\ClearOldStatusesBackgroundJob</job>
+    </background-jobs>
+</info>

+ 43 - 0
apps/user_status/appinfo/routes.php

@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+return [
+	'ocs' => [
+		// Routes for querying statuses
+		['name' => 'Statuses#findAll', 'url' => '/api/v1/statuses', 'verb' => 'GET'],
+		['name' => 'Statuses#find', 'url' => '/api/v1/statuses/{userId}', 'verb' => 'GET'],
+		// Routes for manipulating your own status
+		['name' => 'UserStatus#getStatus', 'url' => '/api/v1/user_status', 'verb' => 'GET'],
+		['name' => 'UserStatus#setStatus', 'url' => '/api/v1/user_status/status', 'verb' => 'PUT'],
+		['name' => 'UserStatus#setPredefinedMessage', 'url' => '/api/v1/user_status/message/predefined', 'verb' => 'PUT'],
+		['name' => 'UserStatus#setCustomMessage', 'url' => '/api/v1/user_status/message/custom', 'verb' => 'PUT'],
+		['name' => 'UserStatus#clearMessage', 'url' => '/api/v1/user_status/message', 'verb' => 'DELETE'],
+		// Routes for listing default routes
+		['name' => 'PredefinedStatus#findAll', 'url' => '/api/v1/predefined_statuses/', 'verb' => 'GET']
+	],
+	'routes' => [
+		['name' => 'Heartbeat#heartbeat', 'url' => '/heartbeat', 'verb' => 'PUT'],
+	],
+];

+ 7 - 0
apps/user_status/composer/autoload.php

@@ -0,0 +1,7 @@
+<?php
+
+// autoload.php @generated by Composer
+
+require_once __DIR__ . '/composer/autoload_real.php';
+
+return ComposerAutoloaderInitUserStatus::getLoader();

+ 13 - 0
apps/user_status/composer/composer.json

@@ -0,0 +1,13 @@
+{
+	"config" : {
+		"vendor-dir": ".",
+		"optimize-autoloader": true,
+		"classmap-authoritative": true,
+		"autoloader-suffix": "UserStatus"
+	},
+	"autoload" : {
+		"psr-4": {
+			"OCA\\UserStatus\\": "../lib/"
+		}
+	}
+}

+ 445 - 0
apps/user_status/composer/composer/ClassLoader.php

@@ -0,0 +1,445 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Autoload;
+
+/**
+ * ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
+ *
+ *     $loader = new \Composer\Autoload\ClassLoader();
+ *
+ *     // register classes with namespaces
+ *     $loader->add('Symfony\Component', __DIR__.'/component');
+ *     $loader->add('Symfony',           __DIR__.'/framework');
+ *
+ *     // activate the autoloader
+ *     $loader->register();
+ *
+ *     // to enable searching the include path (eg. for PEAR packages)
+ *     $loader->setUseIncludePath(true);
+ *
+ * In this example, if you try to use a class in the Symfony\Component
+ * namespace or one of its children (Symfony\Component\Console for instance),
+ * the autoloader will first look for the class under the component/
+ * directory, and it will then fallback to the framework/ directory if not
+ * found before giving up.
+ *
+ * This class is loosely based on the Symfony UniversalClassLoader.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ * @see    http://www.php-fig.org/psr/psr-0/
+ * @see    http://www.php-fig.org/psr/psr-4/
+ */
+class ClassLoader
+{
+    // PSR-4
+    private $prefixLengthsPsr4 = array();
+    private $prefixDirsPsr4 = array();
+    private $fallbackDirsPsr4 = array();
+
+    // PSR-0
+    private $prefixesPsr0 = array();
+    private $fallbackDirsPsr0 = array();
+
+    private $useIncludePath = false;
+    private $classMap = array();
+    private $classMapAuthoritative = false;
+    private $missingClasses = array();
+    private $apcuPrefix;
+
+    public function getPrefixes()
+    {
+        if (!empty($this->prefixesPsr0)) {
+            return call_user_func_array('array_merge', $this->prefixesPsr0);
+        }
+
+        return array();
+    }
+
+    public function getPrefixesPsr4()
+    {
+        return $this->prefixDirsPsr4;
+    }
+
+    public function getFallbackDirs()
+    {
+        return $this->fallbackDirsPsr0;
+    }
+
+    public function getFallbackDirsPsr4()
+    {
+        return $this->fallbackDirsPsr4;
+    }
+
+    public function getClassMap()
+    {
+        return $this->classMap;
+    }
+
+    /**
+     * @param array $classMap Class to filename map
+     */
+    public function addClassMap(array $classMap)
+    {
+        if ($this->classMap) {
+            $this->classMap = array_merge($this->classMap, $classMap);
+        } else {
+            $this->classMap = $classMap;
+        }
+    }
+
+    /**
+     * Registers a set of PSR-0 directories for a given prefix, either
+     * appending or prepending to the ones previously set for this prefix.
+     *
+     * @param string       $prefix  The prefix
+     * @param array|string $paths   The PSR-0 root directories
+     * @param bool         $prepend Whether to prepend the directories
+     */
+    public function add($prefix, $paths, $prepend = false)
+    {
+        if (!$prefix) {
+            if ($prepend) {
+                $this->fallbackDirsPsr0 = array_merge(
+                    (array) $paths,
+                    $this->fallbackDirsPsr0
+                );
+            } else {
+                $this->fallbackDirsPsr0 = array_merge(
+                    $this->fallbackDirsPsr0,
+                    (array) $paths
+                );
+            }
+
+            return;
+        }
+
+        $first = $prefix[0];
+        if (!isset($this->prefixesPsr0[$first][$prefix])) {
+            $this->prefixesPsr0[$first][$prefix] = (array) $paths;
+
+            return;
+        }
+        if ($prepend) {
+            $this->prefixesPsr0[$first][$prefix] = array_merge(
+                (array) $paths,
+                $this->prefixesPsr0[$first][$prefix]
+            );
+        } else {
+            $this->prefixesPsr0[$first][$prefix] = array_merge(
+                $this->prefixesPsr0[$first][$prefix],
+                (array) $paths
+            );
+        }
+    }
+
+    /**
+     * Registers a set of PSR-4 directories for a given namespace, either
+     * appending or prepending to the ones previously set for this namespace.
+     *
+     * @param string       $prefix  The prefix/namespace, with trailing '\\'
+     * @param array|string $paths   The PSR-4 base directories
+     * @param bool         $prepend Whether to prepend the directories
+     *
+     * @throws \InvalidArgumentException
+     */
+    public function addPsr4($prefix, $paths, $prepend = false)
+    {
+        if (!$prefix) {
+            // Register directories for the root namespace.
+            if ($prepend) {
+                $this->fallbackDirsPsr4 = array_merge(
+                    (array) $paths,
+                    $this->fallbackDirsPsr4
+                );
+            } else {
+                $this->fallbackDirsPsr4 = array_merge(
+                    $this->fallbackDirsPsr4,
+                    (array) $paths
+                );
+            }
+        } elseif (!isset($this->prefixDirsPsr4[$prefix])) {
+            // Register directories for a new namespace.
+            $length = strlen($prefix);
+            if ('\\' !== $prefix[$length - 1]) {
+                throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
+            }
+            $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
+            $this->prefixDirsPsr4[$prefix] = (array) $paths;
+        } elseif ($prepend) {
+            // Prepend directories for an already registered namespace.
+            $this->prefixDirsPsr4[$prefix] = array_merge(
+                (array) $paths,
+                $this->prefixDirsPsr4[$prefix]
+            );
+        } else {
+            // Append directories for an already registered namespace.
+            $this->prefixDirsPsr4[$prefix] = array_merge(
+                $this->prefixDirsPsr4[$prefix],
+                (array) $paths
+            );
+        }
+    }
+
+    /**
+     * Registers a set of PSR-0 directories for a given prefix,
+     * replacing any others previously set for this prefix.
+     *
+     * @param string       $prefix The prefix
+     * @param array|string $paths  The PSR-0 base directories
+     */
+    public function set($prefix, $paths)
+    {
+        if (!$prefix) {
+            $this->fallbackDirsPsr0 = (array) $paths;
+        } else {
+            $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
+        }
+    }
+
+    /**
+     * Registers a set of PSR-4 directories for a given namespace,
+     * replacing any others previously set for this namespace.
+     *
+     * @param string       $prefix The prefix/namespace, with trailing '\\'
+     * @param array|string $paths  The PSR-4 base directories
+     *
+     * @throws \InvalidArgumentException
+     */
+    public function setPsr4($prefix, $paths)
+    {
+        if (!$prefix) {
+            $this->fallbackDirsPsr4 = (array) $paths;
+        } else {
+            $length = strlen($prefix);
+            if ('\\' !== $prefix[$length - 1]) {
+                throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
+            }
+            $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
+            $this->prefixDirsPsr4[$prefix] = (array) $paths;
+        }
+    }
+
+    /**
+     * Turns on searching the include path for class files.
+     *
+     * @param bool $useIncludePath
+     */
+    public function setUseIncludePath($useIncludePath)
+    {
+        $this->useIncludePath = $useIncludePath;
+    }
+
+    /**
+     * Can be used to check if the autoloader uses the include path to check
+     * for classes.
+     *
+     * @return bool
+     */
+    public function getUseIncludePath()
+    {
+        return $this->useIncludePath;
+    }
+
+    /**
+     * Turns off searching the prefix and fallback directories for classes
+     * that have not been registered with the class map.
+     *
+     * @param bool $classMapAuthoritative
+     */
+    public function setClassMapAuthoritative($classMapAuthoritative)
+    {
+        $this->classMapAuthoritative = $classMapAuthoritative;
+    }
+
+    /**
+     * Should class lookup fail if not found in the current class map?
+     *
+     * @return bool
+     */
+    public function isClassMapAuthoritative()
+    {
+        return $this->classMapAuthoritative;
+    }
+
+    /**
+     * APCu prefix to use to cache found/not-found classes, if the extension is enabled.
+     *
+     * @param string|null $apcuPrefix
+     */
+    public function setApcuPrefix($apcuPrefix)
+    {
+        $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
+    }
+
+    /**
+     * The APCu prefix in use, or null if APCu caching is not enabled.
+     *
+     * @return string|null
+     */
+    public function getApcuPrefix()
+    {
+        return $this->apcuPrefix;
+    }
+
+    /**
+     * Registers this instance as an autoloader.
+     *
+     * @param bool $prepend Whether to prepend the autoloader or not
+     */
+    public function register($prepend = false)
+    {
+        spl_autoload_register(array($this, 'loadClass'), true, $prepend);
+    }
+
+    /**
+     * Unregisters this instance as an autoloader.
+     */
+    public function unregister()
+    {
+        spl_autoload_unregister(array($this, 'loadClass'));
+    }
+
+    /**
+     * Loads the given class or interface.
+     *
+     * @param  string    $class The name of the class
+     * @return bool|null True if loaded, null otherwise
+     */
+    public function loadClass($class)
+    {
+        if ($file = $this->findFile($class)) {
+            includeFile($file);
+
+            return true;
+        }
+    }
+
+    /**
+     * Finds the path to the file where the class is defined.
+     *
+     * @param string $class The name of the class
+     *
+     * @return string|false The path if found, false otherwise
+     */
+    public function findFile($class)
+    {
+        // class map lookup
+        if (isset($this->classMap[$class])) {
+            return $this->classMap[$class];
+        }
+        if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
+            return false;
+        }
+        if (null !== $this->apcuPrefix) {
+            $file = apcu_fetch($this->apcuPrefix.$class, $hit);
+            if ($hit) {
+                return $file;
+            }
+        }
+
+        $file = $this->findFileWithExtension($class, '.php');
+
+        // Search for Hack files if we are running on HHVM
+        if (false === $file && defined('HHVM_VERSION')) {
+            $file = $this->findFileWithExtension($class, '.hh');
+        }
+
+        if (null !== $this->apcuPrefix) {
+            apcu_add($this->apcuPrefix.$class, $file);
+        }
+
+        if (false === $file) {
+            // Remember that this class does not exist.
+            $this->missingClasses[$class] = true;
+        }
+
+        return $file;
+    }
+
+    private function findFileWithExtension($class, $ext)
+    {
+        // PSR-4 lookup
+        $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
+
+        $first = $class[0];
+        if (isset($this->prefixLengthsPsr4[$first])) {
+            $subPath = $class;
+            while (false !== $lastPos = strrpos($subPath, '\\')) {
+                $subPath = substr($subPath, 0, $lastPos);
+                $search = $subPath . '\\';
+                if (isset($this->prefixDirsPsr4[$search])) {
+                    $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
+                    foreach ($this->prefixDirsPsr4[$search] as $dir) {
+                        if (file_exists($file = $dir . $pathEnd)) {
+                            return $file;
+                        }
+                    }
+                }
+            }
+        }
+
+        // PSR-4 fallback dirs
+        foreach ($this->fallbackDirsPsr4 as $dir) {
+            if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
+                return $file;
+            }
+        }
+
+        // PSR-0 lookup
+        if (false !== $pos = strrpos($class, '\\')) {
+            // namespaced class name
+            $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
+                . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
+        } else {
+            // PEAR-like class name
+            $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
+        }
+
+        if (isset($this->prefixesPsr0[$first])) {
+            foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
+                if (0 === strpos($class, $prefix)) {
+                    foreach ($dirs as $dir) {
+                        if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
+                            return $file;
+                        }
+                    }
+                }
+            }
+        }
+
+        // PSR-0 fallback dirs
+        foreach ($this->fallbackDirsPsr0 as $dir) {
+            if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
+                return $file;
+            }
+        }
+
+        // PSR-0 include paths.
+        if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
+            return $file;
+        }
+
+        return false;
+    }
+}
+
+/**
+ * Scope isolated include.
+ *
+ * Prevents access to $this/self from included files.
+ */
+function includeFile($file)
+{
+    include $file;
+}

+ 21 - 0
apps/user_status/composer/composer/LICENSE

@@ -0,0 +1,21 @@
+
+Copyright (c) Nils Adermann, Jordi Boggiano
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+

+ 31 - 0
apps/user_status/composer/composer/autoload_classmap.php

@@ -0,0 +1,31 @@
+<?php
+
+// autoload_classmap.php @generated by Composer
+
+$vendorDir = dirname(dirname(__FILE__));
+$baseDir = $vendorDir;
+
+return array(
+    'OCA\\UserStatus\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
+    'OCA\\UserStatus\\BackgroundJob\\ClearOldStatusesBackgroundJob' => $baseDir . '/../lib/BackgroundJob/ClearOldStatusesBackgroundJob.php',
+    'OCA\\UserStatus\\Capabilities' => $baseDir . '/../lib/Capabilities.php',
+    'OCA\\UserStatus\\Controller\\HeartbeatController' => $baseDir . '/../lib/Controller/HeartbeatController.php',
+    'OCA\\UserStatus\\Controller\\PredefinedStatusController' => $baseDir . '/../lib/Controller/PredefinedStatusController.php',
+    'OCA\\UserStatus\\Controller\\StatusesController' => $baseDir . '/../lib/Controller/StatusesController.php',
+    'OCA\\UserStatus\\Controller\\UserStatusController' => $baseDir . '/../lib/Controller/UserStatusController.php',
+    'OCA\\UserStatus\\Db\\UserStatus' => $baseDir . '/../lib/Db/UserStatus.php',
+    'OCA\\UserStatus\\Db\\UserStatusMapper' => $baseDir . '/../lib/Db/UserStatusMapper.php',
+    'OCA\\UserStatus\\Exception\\InvalidClearAtException' => $baseDir . '/../lib/Exception/InvalidClearAtException.php',
+    'OCA\\UserStatus\\Exception\\InvalidMessageIdException' => $baseDir . '/../lib/Exception/InvalidMessageIdException.php',
+    'OCA\\UserStatus\\Exception\\InvalidStatusIconException' => $baseDir . '/../lib/Exception/InvalidStatusIconException.php',
+    'OCA\\UserStatus\\Exception\\InvalidStatusTypeException' => $baseDir . '/../lib/Exception/InvalidStatusTypeException.php',
+    'OCA\\UserStatus\\Exception\\StatusMessageTooLongException' => $baseDir . '/../lib/Exception/StatusMessageTooLongException.php',
+    'OCA\\UserStatus\\Listener\\BeforeTemplateRenderedListener' => $baseDir . '/../lib/Listener/BeforeTemplateRenderedListener.php',
+    'OCA\\UserStatus\\Listener\\UserDeletedListener' => $baseDir . '/../lib/Listener/UserDeletedListener.php',
+    'OCA\\UserStatus\\Listener\\UserLiveStatusListener' => $baseDir . '/../lib/Listener/UserLiveStatusListener.php',
+    'OCA\\UserStatus\\Migration\\Version0001Date20200602134824' => $baseDir . '/../lib/Migration/Version0001Date20200602134824.php',
+    'OCA\\UserStatus\\Service\\EmojiService' => $baseDir . '/../lib/Service/EmojiService.php',
+    'OCA\\UserStatus\\Service\\JSDataService' => $baseDir . '/../lib/Service/JSDataService.php',
+    'OCA\\UserStatus\\Service\\PredefinedStatusService' => $baseDir . '/../lib/Service/PredefinedStatusService.php',
+    'OCA\\UserStatus\\Service\\StatusService' => $baseDir . '/../lib/Service/StatusService.php',
+);

+ 9 - 0
apps/user_status/composer/composer/autoload_namespaces.php

@@ -0,0 +1,9 @@
+<?php
+
+// autoload_namespaces.php @generated by Composer
+
+$vendorDir = dirname(dirname(__FILE__));
+$baseDir = $vendorDir;
+
+return array(
+);

+ 10 - 0
apps/user_status/composer/composer/autoload_psr4.php

@@ -0,0 +1,10 @@
+<?php
+
+// autoload_psr4.php @generated by Composer
+
+$vendorDir = dirname(dirname(__FILE__));
+$baseDir = $vendorDir;
+
+return array(
+    'OCA\\UserStatus\\' => array($baseDir . '/../lib'),
+);

+ 46 - 0
apps/user_status/composer/composer/autoload_real.php

@@ -0,0 +1,46 @@
+<?php
+
+// autoload_real.php @generated by Composer
+
+class ComposerAutoloaderInitUserStatus
+{
+    private static $loader;
+
+    public static function loadClassLoader($class)
+    {
+        if ('Composer\Autoload\ClassLoader' === $class) {
+            require __DIR__ . '/ClassLoader.php';
+        }
+    }
+
+    /**
+     * @return \Composer\Autoload\ClassLoader
+     */
+    public static function getLoader()
+    {
+        if (null !== self::$loader) {
+            return self::$loader;
+        }
+
+        spl_autoload_register(array('ComposerAutoloaderInitUserStatus', 'loadClassLoader'), true, true);
+        self::$loader = $loader = new \Composer\Autoload\ClassLoader();
+        spl_autoload_unregister(array('ComposerAutoloaderInitUserStatus', 'loadClassLoader'));
+
+        $useStaticLoader = PHP_VERSION_ID >= 50600 && !defined('HHVM_VERSION') && (!function_exists('zend_loader_file_encoded') || !zend_loader_file_encoded());
+        if ($useStaticLoader) {
+            require_once __DIR__ . '/autoload_static.php';
+
+            call_user_func(\Composer\Autoload\ComposerStaticInitUserStatus::getInitializer($loader));
+        } else {
+            $classMap = require __DIR__ . '/autoload_classmap.php';
+            if ($classMap) {
+                $loader->addClassMap($classMap);
+            }
+        }
+
+        $loader->setClassMapAuthoritative(true);
+        $loader->register(true);
+
+        return $loader;
+    }
+}

+ 57 - 0
apps/user_status/composer/composer/autoload_static.php

@@ -0,0 +1,57 @@
+<?php
+
+// autoload_static.php @generated by Composer
+
+namespace Composer\Autoload;
+
+class ComposerStaticInitUserStatus
+{
+    public static $prefixLengthsPsr4 = array (
+        'O' => 
+        array (
+            'OCA\\UserStatus\\' => 15,
+        ),
+    );
+
+    public static $prefixDirsPsr4 = array (
+        'OCA\\UserStatus\\' => 
+        array (
+            0 => __DIR__ . '/..' . '/../lib',
+        ),
+    );
+
+    public static $classMap = array (
+        'OCA\\UserStatus\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
+        'OCA\\UserStatus\\BackgroundJob\\ClearOldStatusesBackgroundJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/ClearOldStatusesBackgroundJob.php',
+        'OCA\\UserStatus\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php',
+        'OCA\\UserStatus\\Controller\\HeartbeatController' => __DIR__ . '/..' . '/../lib/Controller/HeartbeatController.php',
+        'OCA\\UserStatus\\Controller\\PredefinedStatusController' => __DIR__ . '/..' . '/../lib/Controller/PredefinedStatusController.php',
+        'OCA\\UserStatus\\Controller\\StatusesController' => __DIR__ . '/..' . '/../lib/Controller/StatusesController.php',
+        'OCA\\UserStatus\\Controller\\UserStatusController' => __DIR__ . '/..' . '/../lib/Controller/UserStatusController.php',
+        'OCA\\UserStatus\\Db\\UserStatus' => __DIR__ . '/..' . '/../lib/Db/UserStatus.php',
+        'OCA\\UserStatus\\Db\\UserStatusMapper' => __DIR__ . '/..' . '/../lib/Db/UserStatusMapper.php',
+        'OCA\\UserStatus\\Exception\\InvalidClearAtException' => __DIR__ . '/..' . '/../lib/Exception/InvalidClearAtException.php',
+        'OCA\\UserStatus\\Exception\\InvalidMessageIdException' => __DIR__ . '/..' . '/../lib/Exception/InvalidMessageIdException.php',
+        'OCA\\UserStatus\\Exception\\InvalidStatusIconException' => __DIR__ . '/..' . '/../lib/Exception/InvalidStatusIconException.php',
+        'OCA\\UserStatus\\Exception\\InvalidStatusTypeException' => __DIR__ . '/..' . '/../lib/Exception/InvalidStatusTypeException.php',
+        'OCA\\UserStatus\\Exception\\StatusMessageTooLongException' => __DIR__ . '/..' . '/../lib/Exception/StatusMessageTooLongException.php',
+        'OCA\\UserStatus\\Listener\\BeforeTemplateRenderedListener' => __DIR__ . '/..' . '/../lib/Listener/BeforeTemplateRenderedListener.php',
+        'OCA\\UserStatus\\Listener\\UserDeletedListener' => __DIR__ . '/..' . '/../lib/Listener/UserDeletedListener.php',
+        'OCA\\UserStatus\\Listener\\UserLiveStatusListener' => __DIR__ . '/..' . '/../lib/Listener/UserLiveStatusListener.php',
+        'OCA\\UserStatus\\Migration\\Version0001Date20200602134824' => __DIR__ . '/..' . '/../lib/Migration/Version0001Date20200602134824.php',
+        'OCA\\UserStatus\\Service\\EmojiService' => __DIR__ . '/..' . '/../lib/Service/EmojiService.php',
+        'OCA\\UserStatus\\Service\\JSDataService' => __DIR__ . '/..' . '/../lib/Service/JSDataService.php',
+        'OCA\\UserStatus\\Service\\PredefinedStatusService' => __DIR__ . '/..' . '/../lib/Service/PredefinedStatusService.php',
+        'OCA\\UserStatus\\Service\\StatusService' => __DIR__ . '/..' . '/../lib/Service/StatusService.php',
+    );
+
+    public static function getInitializer(ClassLoader $loader)
+    {
+        return \Closure::bind(function () use ($loader) {
+            $loader->prefixLengthsPsr4 = ComposerStaticInitUserStatus::$prefixLengthsPsr4;
+            $loader->prefixDirsPsr4 = ComposerStaticInitUserStatus::$prefixDirsPsr4;
+            $loader->classMap = ComposerStaticInitUserStatus::$classMap;
+
+        }, null, ClassLoader::class);
+    }
+}

+ 37 - 0
apps/user_status/css/user-status-menu.scss

@@ -0,0 +1,37 @@
+/**
+ * @copyright Copyright (c) 2020 Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+.icon-user-status-away {
+	@include icon-color('user-status-away', 'user_status', '#F4A331', 1);
+}
+
+.icon-user-status-dnd {
+	@include icon-color('user-status-dnd', 'user_status', '#ED484C', 1);
+}
+
+.icon-user-status-invisible {
+	@include icon-color('user-status-invisible', 'user_status', '#000000', 1);
+}
+
+.icon-user-status-online {
+	@include icon-color('user-status-online', 'user_status', '#49B382', 2);
+}

+ 1 - 0
apps/user_status/img/app.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><rect fill="none" height="24" width="24"/></g><g><g><g><path d="M12,2C6.5,2,2,6.5,2,12s4.5,10,10,10s10-4.5,10-10S17.5,2,12,2z M16.2,16.2L11,13V7h1.5v5.2l4.5,2.7L16.2,16.2z"/></g></g></g></svg>

+ 1 - 0
apps/user_status/img/user-status-away.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><rect fill="none" height="24" width="24"/></g><g><g><g><path fill="#F4A331" d="M12,2C6.5,2,2,6.5,2,12s4.5,10,10,10s10-4.5,10-10S17.5,2,12,2z M16.2,16.2L11,13V7h1.5v5.2l4.5,2.7L16.2,16.2z"/></g></g></g></svg>

+ 1 - 0
apps/user_status/img/user-status-dnd.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path fill="#ED484C" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11H7v-2h10v2z"/></svg>

+ 1 - 0
apps/user_status/img/user-status-invisible.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg>

+ 1 - 0
apps/user_status/img/user-status-online.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><path fill="#49B382" d="M8,16h8V8H8V16z M12,2C6.48,2,2,6.48,2,12s4.48,10,10,10s10-4.48,10-10 S17.52,2,12,2L12,2z"/></g></svg>

File diff suppressed because it is too large
+ 0 - 0
apps/user_status/js/user-status-menu.js


File diff suppressed because it is too large
+ 0 - 0
apps/user_status/js/user-status-menu.js.map


+ 74 - 0
apps/user_status/lib/AppInfo/Application.php

@@ -0,0 +1,74 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\AppInfo;
+
+use OCA\UserStatus\Capabilities;
+use OCA\UserStatus\Listener\BeforeTemplateRenderedListener;
+use OCA\UserStatus\Listener\UserDeletedListener;
+use OCA\UserStatus\Listener\UserLiveStatusListener;
+use OCP\AppFramework\App;
+use OCP\AppFramework\Bootstrap\IBootContext;
+use OCP\AppFramework\Bootstrap\IBootstrap;
+use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
+use OCP\User\Events\UserDeletedEvent;
+use OCP\User\Events\UserLiveStatusEvent;
+
+/**
+ * Class Application
+ *
+ * @package OCA\UserStatus\AppInfo
+ */
+class Application extends App implements IBootstrap {
+
+	/** @var string */
+	public const APP_ID = 'user_status';
+
+	/**
+	 * Application constructor.
+	 *
+	 * @param array $urlParams
+	 */
+	public function __construct(array $urlParams = []) {
+		parent::__construct(self::APP_ID, $urlParams);
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	public function register(IRegistrationContext $context): void {
+		// Register OCS Capabilities
+		$context->registerCapability(Capabilities::class);
+
+		// Register Event Listeners
+		$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
+		$context->registerEventListener(UserLiveStatusEvent::class, UserLiveStatusListener::class);
+		$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
+	}
+
+	public function boot(IBootContext $context): void {
+	}
+}

+ 63 - 0
apps/user_status/lib/BackgroundJob/ClearOldStatusesBackgroundJob.php

@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\BackgroundJob;
+
+use OCA\UserStatus\Db\UserStatusMapper;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+
+/**
+ * Class ClearOldStatusesBackgroundJob
+ *
+ * @package OCA\UserStatus\BackgroundJob
+ */
+class ClearOldStatusesBackgroundJob extends TimedJob {
+
+	/** @var UserStatusMapper */
+	private $mapper;
+
+	/**
+	 * ClearOldStatusesBackgroundJob constructor.
+	 *
+	 * @param ITimeFactory $time
+	 * @param UserStatusMapper $mapper
+	 */
+	public function __construct(ITimeFactory $time,
+								UserStatusMapper $mapper) {
+		parent::__construct($time);
+		$this->mapper = $mapper;
+
+		// Run every time the cron is run
+		$this->setInterval(60);
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	protected function run($argument) {
+		$this->mapper->clearOlderThan($this->time->getTime());
+	}
+}

+ 60 - 0
apps/user_status/lib/Capabilities.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+namespace OCA\UserStatus;
+
+use OCA\UserStatus\Service\EmojiService;
+use OCP\Capabilities\ICapability;
+
+/**
+ * Class Capabilities
+ *
+ * @package OCA\UserStatus
+ */
+class Capabilities implements ICapability {
+
+	/** @var EmojiService */
+	private $emojiService;
+
+	/**
+	 * Capabilities constructor.
+	 *
+	 * @param EmojiService $emojiService
+	 */
+	public function __construct(EmojiService $emojiService) {
+		$this->emojiService = $emojiService;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	public function getCapabilities() {
+		return [
+			'user_status' => [
+				'enabled' => true,
+				'supports_emoji' => $this->emojiService->doesPlatformSupportEmoji(),
+			],
+		];
+	}
+}

+ 92 - 0
apps/user_status/lib/Controller/HeartbeatController.php

@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Controller;
+
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IRequest;
+use OCP\IUserSession;
+use OCP\User\Events\UserLiveStatusEvent;
+
+class HeartbeatController extends Controller {
+
+	/** @var IEventDispatcher */
+	private $eventDispatcher;
+
+	/** @var IUserSession */
+	private $userSession;
+
+	/** @var ITimeFactory */
+	private $timeFactory;
+
+	/**
+	 * HeartbeatController constructor.
+	 *
+	 * @param string $appName
+	 * @param IRequest $request
+	 * @param IEventDispatcher $eventDispatcher
+	 */
+	public function __construct(string $appName,
+								IRequest $request,
+								IEventDispatcher $eventDispatcher,
+								IUserSession $userSession,
+								ITimeFactory $timeFactory) {
+		parent::__construct($appName, $request);
+		$this->eventDispatcher = $eventDispatcher;
+		$this->userSession = $userSession;
+		$this->timeFactory = $timeFactory;
+	}
+
+	/**
+	 * @NoAdminRequired
+	 *
+	 * @param string $status
+	 * @return JSONResponse
+	 */
+	public function heartbeat(string $status): JSONResponse {
+		if (!\in_array($status, ['online', 'away'])) {
+			return new JSONResponse([], Http::STATUS_BAD_REQUEST);
+		}
+
+		$user = $this->userSession->getUser();
+		if ($user === null) {
+			return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
+		}
+
+		$this->eventDispatcher->dispatchTyped(
+			new UserLiveStatusEvent(
+				$user,
+				$status,
+				$this->timeFactory->getTime()
+			)
+		);
+
+		return new JSONResponse([], Http::STATUS_NO_CONTENT);
+	}
+}

+ 65 - 0
apps/user_status/lib/Controller/PredefinedStatusController.php

@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Controller;
+
+use OCA\UserStatus\Service\PredefinedStatusService;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCSController;
+use OCP\IRequest;
+
+/**
+ * Class DefaultStatusController
+ *
+ * @package OCA\UserStatus\Controller
+ */
+class PredefinedStatusController extends OCSController {
+
+	/** @var PredefinedStatusService */
+	private $predefinedStatusService;
+
+	/**
+	 * AStatusController constructor.
+	 *
+	 * @param string $appName
+	 * @param IRequest $request
+	 * @param PredefinedStatusService $predefinedStatusService
+	 */
+	public function __construct(string $appName,
+								IRequest $request,
+								PredefinedStatusService $predefinedStatusService) {
+		parent::__construct($appName, $request);
+		$this->predefinedStatusService = $predefinedStatusService;
+	}
+
+	/**
+	 * @NoAdminRequired
+	 *
+	 * @return DataResponse
+	 */
+	public function findAll():DataResponse {
+		return new DataResponse($this->predefinedStatusService->getDefaultStatuses());
+	}
+}

+ 107 - 0
apps/user_status/lib/Controller/StatusesController.php

@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Controller;
+
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Service\StatusService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCS\OCSNotFoundException;
+use OCP\AppFramework\OCSController;
+use OCP\IRequest;
+
+class StatusesController extends OCSController {
+
+	/** @var StatusService */
+	private $service;
+
+	/**
+	 * StatusesController constructor.
+	 *
+	 * @param string $appName
+	 * @param IRequest $request
+	 * @param StatusService $service
+	 */
+	public function __construct(string $appName,
+								IRequest $request,
+								StatusService $service) {
+		parent::__construct($appName, $request);
+		$this->service = $service;
+	}
+
+	/**
+	 * @NoAdminRequired
+	 *
+	 * @param int|null $limit
+	 * @param int|null $offset
+	 * @return DataResponse
+	 */
+	public function findAll(?int $limit=null, ?int $offset=null): DataResponse {
+		$allStatuses = $this->service->findAll($limit, $offset);
+
+		return new DataResponse(array_map(function ($userStatus) {
+			return $this->formatStatus($userStatus);
+		}, $allStatuses));
+	}
+
+	/**
+	 * @NoAdminRequired
+	 *
+	 * @param string $userId
+	 * @return DataResponse
+	 * @throws OCSNotFoundException
+	 */
+	public function find(string $userId): DataResponse {
+		try {
+			$userStatus = $this->service->findByUserId($userId);
+		} catch (DoesNotExistException $ex) {
+			throw new OCSNotFoundException('No status for the requested userId');
+		}
+
+		return new DataResponse($this->formatStatus($userStatus));
+	}
+
+	/**
+	 * @NoAdminRequired
+	 *
+	 * @param UserStatus $status
+	 * @return array
+	 */
+	private function formatStatus(UserStatus $status): array {
+		$visibleStatus = $status->getStatus();
+		if ($visibleStatus === 'invisible') {
+			$visibleStatus = 'offline';
+		}
+
+		return [
+			'userId' => $status->getUserId(),
+			'message' => $status->getCustomMessage(),
+			'icon' => $status->getCustomIcon(),
+			'clearAt' => $status->getClearAt(),
+			'status' => $visibleStatus,
+		];
+	}
+}

+ 191 - 0
apps/user_status/lib/Controller/UserStatusController.php

@@ -0,0 +1,191 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+namespace OCA\UserStatus\Controller;
+
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Exception\InvalidClearAtException;
+use OCA\UserStatus\Exception\InvalidMessageIdException;
+use OCA\UserStatus\Exception\InvalidStatusIconException;
+use OCA\UserStatus\Exception\InvalidStatusTypeException;
+use OCA\UserStatus\Exception\StatusMessageTooLongException;
+use OCA\UserStatus\Service\StatusService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCS\OCSBadRequestException;
+use OCP\AppFramework\OCS\OCSNotFoundException;
+use OCP\AppFramework\OCSController;
+use OCP\ILogger;
+use OCP\IRequest;
+
+class UserStatusController extends OCSController {
+
+	/** @var string */
+	private $userId;
+
+	/** @var ILogger */
+	private $logger;
+
+	/** @var StatusService */
+	private $service;
+
+	/**
+	 * StatusesController constructor.
+	 *
+	 * @param string $appName
+	 * @param IRequest $request
+	 * @param string $userId
+	 * @param ILogger $logger;
+	 * @param StatusService $service
+	 */
+	public function __construct(string $appName,
+								IRequest $request,
+								string $userId,
+								ILogger $logger,
+								StatusService $service) {
+		parent::__construct($appName, $request);
+		$this->userId = $userId;
+		$this->logger = $logger;
+		$this->service = $service;
+	}
+
+	/**
+	 * @NoAdminRequired
+	 *
+	 * @return DataResponse
+	 * @throws OCSNotFoundException
+	 */
+	public function getStatus(): DataResponse {
+		try {
+			$userStatus = $this->service->findByUserId($this->userId);
+		} catch (DoesNotExistException $ex) {
+			throw new OCSNotFoundException('No status for the current user');
+		}
+
+		return new DataResponse($this->formatStatus($userStatus));
+	}
+
+	/**
+	 * @NoAdminRequired
+	 *
+	 * @param string $statusType
+	 * @return DataResponse
+	 * @throws OCSBadRequestException
+	 */
+	public function setStatus(string $statusType): DataResponse {
+		try {
+			$status = $this->service->setStatus($this->userId, $statusType, null, true);
+			return new DataResponse($this->formatStatus($status));
+		} catch (InvalidStatusTypeException $ex) {
+			$this->logger->debug('New user-status for "' . $this->userId . '" was rejected due to an invalid status type "' . $statusType . '"');
+			throw new OCSBadRequestException($ex->getMessage(), $ex);
+		}
+	}
+
+	/**
+	 * @NoAdminRequired
+	 *
+	 * @param string $messageId
+	 * @param int|null $clearAt
+	 * @return DataResponse
+	 * @throws OCSBadRequestException
+	 */
+	public function setPredefinedMessage(string $messageId,
+										 ?int $clearAt): DataResponse {
+		try {
+			$status = $this->service->setPredefinedMessage($this->userId, $messageId, $clearAt);
+			return new DataResponse($this->formatStatus($status));
+		} catch (InvalidClearAtException $ex) {
+			$this->logger->debug('New user-status for "' . $this->userId . '" was rejected due to an invalid clearAt value "' . $clearAt . '"');
+			throw new OCSBadRequestException($ex->getMessage(), $ex);
+		} catch (InvalidMessageIdException $ex) {
+			$this->logger->debug('New user-status for "' . $this->userId . '" was rejected due to an invalid message-id "' . $messageId . '"');
+			throw new OCSBadRequestException($ex->getMessage(), $ex);
+		}
+	}
+
+	/**
+	 * @NoAdminRequired
+	 *
+	 * @param string|null $statusIcon
+	 * @param string $message
+	 * @param int|null $clearAt
+	 * @return DataResponse
+	 * @throws OCSBadRequestException
+	 */
+	public function setCustomMessage(?string $statusIcon,
+									 string $message,
+									 ?int $clearAt): DataResponse {
+		try {
+			$status = $this->service->setCustomMessage($this->userId, $statusIcon, $message, $clearAt);
+			return new DataResponse($this->formatStatus($status));
+		} catch (InvalidClearAtException $ex) {
+			$this->logger->debug('New user-status for "' . $this->userId . '" was rejected due to an invalid clearAt value "' . $clearAt . '"');
+			throw new OCSBadRequestException($ex->getMessage(), $ex);
+		} catch (InvalidStatusIconException $ex) {
+			$this->logger->debug('New user-status for "' . $this->userId . '" was rejected due to an invalid icon value "' . $statusIcon . '"');
+			throw new OCSBadRequestException($ex->getMessage(), $ex);
+		} catch (StatusMessageTooLongException $ex) {
+			$this->logger->debug('New user-status for "' . $this->userId . '" was rejected due to a too long status message.');
+			throw new OCSBadRequestException($ex->getMessage(), $ex);
+		}
+	}
+
+	/**
+	 * @NoAdminRequired
+	 *
+	 * @return DataResponse
+	 */
+	public function clearStatus(): DataResponse {
+		$this->service->clearStatus($this->userId);
+		return new DataResponse([]);
+	}
+
+	/**
+	 * @NoAdminRequired
+	 *
+	 * @return DataResponse
+	 */
+	public function clearMessage(): DataResponse {
+		$this->service->clearMessage($this->userId);
+		return new DataResponse([]);
+	}
+
+	/**
+	 * @param UserStatus $status
+	 * @return array
+	 */
+	private function formatStatus(UserStatus $status): array {
+		return [
+			'userId' => $status->getUserId(),
+			'message' => $status->getCustomMessage(),
+			'messageId' => $status->getMessageId(),
+			'messageIsPredefined' => $status->getMessageId() !== null,
+			'icon' => $status->getCustomIcon(),
+			'clearAt' => $status->getClearAt(),
+			'status' => $status->getStatus(),
+			'statusIsUserDefined' => $status->getIsUserDefined(),
+		];
+	}
+}

+ 90 - 0
apps/user_status/lib/Db/UserStatus.php

@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Db;
+
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * Class UserStatus
+ *
+ * @package OCA\UserStatus\Db
+ *
+ * @method int getId()
+ * @method void setId(int $id)
+ * @method string getUserId()
+ * @method void setUserId(string $userId)
+ * @method string getStatus()
+ * @method void setStatus(string $status)
+ * @method int getStatusTimestamp()
+ * @method void setStatusTimestamp(int $statusTimestamp)
+ * @method bool getIsUserDefined()
+ * @method void setIsUserDefined(bool $isUserDefined)
+ * @method string getMessageId()
+ * @method void setMessageId(string|null $messageId)
+ * @method string getCustomIcon()
+ * @method void setCustomIcon(string|null $customIcon)
+ * @method string getCustomMessage()
+ * @method void setCustomMessage(string|null $customMessage)
+ * @method int getClearAt()
+ * @method void setClearAt(int|null $clearAt)
+ */
+class UserStatus extends Entity {
+
+	/** @var string */
+	public $userId;
+
+	/** @var string */
+	public $status;
+
+	/** @var int */
+	public $statusTimestamp;
+
+	/** @var boolean */
+	public $isUserDefined;
+
+	/** @var string|null */
+	public $messageId;
+
+	/** @var string|null */
+	public $customIcon;
+
+	/** @var string|null */
+	public $customMessage;
+
+	/** @var int|null */
+	public $clearAt;
+
+	public function __construct() {
+		$this->addType('userId', 'string');
+		$this->addType('status', 'string');
+		$this->addType('statusTimestamp', 'int');
+		$this->addType('isUserDefined', 'boolean');
+		$this->addType('messageId', 'string');
+		$this->addType('customIcon', 'string');
+		$this->addType('customMessage', 'string');
+		$this->addType('clearAt', 'int');
+	}
+}

+ 104 - 0
apps/user_status/lib/Db/UserStatusMapper.php

@@ -0,0 +1,104 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Db;
+
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+/**
+ * Class UserStatusMapper
+ *
+ * @package OCA\UserStatus\Db
+ *
+ * @method UserStatus insert(UserStatus $entity)
+ * @method UserStatus update(UserStatus $entity)
+ * @method UserStatus insertOrUpdate(UserStatus $entity)
+ * @method UserStatus delete(UserStatus $entity)
+ */
+class UserStatusMapper extends QBMapper {
+
+	/**
+	 * @param IDBConnection $db
+	 */
+	public function __construct(IDBConnection $db) {
+		parent::__construct($db, 'user_status');
+	}
+
+	/**
+	 * @param int|null $limit
+	 * @param int|null $offset
+	 * @return UserStatus[]
+	 */
+	public function findAll(?int $limit = null, ?int $offset = null):array {
+		$qb = $this->db->getQueryBuilder();
+		$qb
+			->select('*')
+			->from($this->tableName);
+
+		if ($limit !== null) {
+			$qb->setMaxResults($limit);
+		}
+		if ($offset !== null) {
+			$qb->setFirstResult($offset);
+		}
+
+		return $this->findEntities($qb);
+	}
+
+	/**
+	 * @param string $userId
+	 * @return UserStatus
+	 * @throws \OCP\AppFramework\Db\DoesNotExistException
+	 */
+	public function findByUserId(string $userId):UserStatus {
+		$qb = $this->db->getQueryBuilder();
+		$qb
+			->select('*')
+			->from($this->tableName)
+			->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)));
+
+		return $this->findEntity($qb);
+	}
+
+	/**
+	 * Clear all statuses older than a given timestamp
+	 *
+	 * @param int $timestamp
+	 */
+	public function clearOlderThan(int $timestamp): void {
+		$qb = $this->db->getQueryBuilder();
+		$qb->update($this->tableName)
+			->set('message_id', $qb->createNamedParameter(null))
+			->set('custom_icon', $qb->createNamedParameter(null))
+			->set('custom_message', $qb->createNamedParameter(null))
+			->set('clear_at', $qb->createNamedParameter(null))
+			->where($qb->expr()->isNotNull('clear_at'))
+			->andWhere($qb->expr()->lte('clear_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)));
+
+		$qb->execute();
+	}
+}

+ 29 - 0
apps/user_status/lib/Exception/InvalidClearAtException.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Exception;
+
+class InvalidClearAtException extends \Exception {
+}

+ 29 - 0
apps/user_status/lib/Exception/InvalidMessageIdException.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Exception;
+
+class InvalidMessageIdException extends \Exception {
+}

+ 29 - 0
apps/user_status/lib/Exception/InvalidStatusIconException.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Exception;
+
+class InvalidStatusIconException extends \Exception {
+}

+ 29 - 0
apps/user_status/lib/Exception/InvalidStatusTypeException.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Exception;
+
+class InvalidStatusTypeException extends \Exception {
+}

+ 29 - 0
apps/user_status/lib/Exception/StatusMessageTooLongException.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Exception;
+
+class StatusMessageTooLongException extends \Exception {
+}

+ 75 - 0
apps/user_status/lib/Listener/BeforeTemplateRenderedListener.php

@@ -0,0 +1,75 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Listener;
+
+use OCA\UserStatus\AppInfo\Application;
+use OCA\UserStatus\Service\JSDataService;
+use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\IInitialStateService;
+
+class BeforeTemplateRenderedListener implements IEventListener {
+
+	/** @var IInitialStateService */
+	private $initialState;
+
+	/** @var JSDataService */
+	private $jsDataService;
+
+	/**
+	 * BeforeTemplateRenderedListener constructor.
+	 *
+	 * @param IInitialStateService $initialState
+	 * @param JSDataService $jsDataService
+	 */
+	public function __construct(IInitialStateService $initialState,
+								JSDataService $jsDataService) {
+		$this->initialState = $initialState;
+		$this->jsDataService = $jsDataService;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	public function handle(Event $event): void {
+		if (!($event instanceof BeforeTemplateRenderedEvent)) {
+			// Unrelated
+			return;
+		}
+
+		if (!$event->isLoggedIn()) {
+			return;
+		}
+
+		$this->initialState->provideLazyInitialState(Application::APP_ID, 'status', function () {
+			return $this->jsDataService;
+		});
+
+		\OCP\Util::addScript('user_status', 'user-status-menu');
+		\OCP\Util::addStyle('user_status', 'user-status-menu');
+	}
+}

+ 65 - 0
apps/user_status/lib/Listener/UserDeletedListener.php

@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Listener;
+
+use OCA\UserStatus\Service\StatusService;
+use OCP\EventDispatcher\IEventListener;
+use OCP\EventDispatcher\Event;
+use OCP\User\Events\UserDeletedEvent;
+
+/**
+ * Class UserDeletedListener
+ *
+ * @package OCA\UserStatus\Listener
+ */
+class UserDeletedListener implements IEventListener {
+
+	/** @var StatusService */
+	private $service;
+
+	/**
+	 * UserDeletedListener constructor.
+	 *
+	 * @param StatusService $service
+	 */
+	public function __construct(StatusService $service) {
+		$this->service = $service;
+	}
+
+
+	/**
+	 * @inheritDoc
+	 */
+	public function handle(Event $event): void {
+		if (!($event instanceof UserDeletedEvent)) {
+			// Unrelated
+			return;
+		}
+
+		$user = $event->getUser();
+		$this->service->removeUserStatus($user->getUID());
+	}
+}

+ 133 - 0
apps/user_status/lib/Listener/UserLiveStatusListener.php

@@ -0,0 +1,133 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Listener;
+
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Db\UserStatusMapper;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\EventDispatcher\IEventListener;
+use OCP\EventDispatcher\Event;
+use OCP\User\Events\UserLiveStatusEvent;
+
+/**
+ * Class UserDeletedListener
+ *
+ * @package OCA\UserStatus\Listener
+ */
+class UserLiveStatusListener implements IEventListener {
+
+	/** @var UserStatusMapper */
+	private $mapper;
+
+	/** @var ITimeFactory */
+	private $timeFactory;
+
+	/** @var string[] */
+	private $priorityOrderedStatuses = [
+		'online',
+		'away',
+		'dnd',
+		'invisible',
+		'offline'
+	];
+
+	/** @var string[] */
+	private $persistentUserStatuses = [
+		'away',
+		'dnd',
+		'invisible',
+	];
+
+	/** @var int */
+	private $offlineThreshold = 300;
+
+	/**
+	 * UserLiveStatusListener constructor.
+	 *
+	 * @param UserStatusMapper $mapper
+	 * @param ITimeFactory $timeFactory
+	 */
+	public function __construct(UserStatusMapper $mapper,
+								ITimeFactory $timeFactory) {
+		$this->mapper = $mapper;
+		$this->timeFactory = $timeFactory;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	public function handle(Event $event): void {
+		if (!($event instanceof UserLiveStatusEvent)) {
+			// Unrelated
+			return;
+		}
+
+		$user = $event->getUser();
+		try {
+			$userStatus = $this->mapper->findByUserId($user->getUID());
+		} catch (DoesNotExistException $ex) {
+			$userStatus = new UserStatus();
+			$userStatus->setUserId($user->getUID());
+			$userStatus->setStatus('offline');
+			$userStatus->setStatusTimestamp(0);
+			$userStatus->setIsUserDefined(false);
+		}
+
+		// If the status is user-defined and one of the persistent statuses, we
+		// will not override it.
+		if ($userStatus->getIsUserDefined() &&
+			\in_array($userStatus->getStatus(), $this->persistentUserStatuses, true)) {
+			return;
+		}
+
+		$needsUpdate = false;
+
+		// If the current status is older than 5 minutes,
+		// treat it as outdated and update
+		if ($userStatus->getStatusTimestamp() < ($this->timeFactory->getTime() - $this->offlineThreshold)) {
+			$needsUpdate = true;
+		}
+
+		// If the emitted status is more important than the current status
+		// treat it as outdated and update
+		if (array_search($event->getStatus(), $this->priorityOrderedStatuses) < array_search($userStatus->getStatus(), $this->priorityOrderedStatuses)) {
+			$needsUpdate = true;
+		}
+
+		if ($needsUpdate) {
+			$userStatus->setStatus($event->getStatus());
+			$userStatus->setStatusTimestamp($event->getTimestamp());
+			$userStatus->setIsUserDefined(false);
+
+			if ($userStatus->getId() === null) {
+				$this->mapper->insert($userStatus);
+			} else {
+				$this->mapper->update($userStatus);
+			}
+		}
+	}
+}

+ 97 - 0
apps/user_status/lib/Migration/Version0001Date20200602134824.php

@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Migration;
+
+use Doctrine\DBAL\Types\Types;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Class Version0001Date20200602134824
+ *
+ * @package OCA\UserStatus\Migration
+ */
+class Version0001Date20200602134824 extends SimpleMigrationStep {
+
+	/**
+	 * @param IOutput $output
+	 * @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
+	 * @param array $options
+	 * @return null|ISchemaWrapper
+	 * @since 20.0.0
+	 */
+	public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options) {
+		/** @var ISchemaWrapper $schema */
+		$schema = $schemaClosure();
+
+		$statusTable = $schema->createTable('user_status');
+		$statusTable->addColumn('id', Types::BIGINT, [
+			'autoincrement' => true,
+			'notnull' => true,
+			'length' => 20,
+			'unsigned' => true,
+		]);
+		$statusTable->addColumn('user_id', Types::STRING, [
+			'notnull' => true,
+			'length' => 255,
+		]);
+		$statusTable->addColumn('status', Types::STRING, [
+			'notnull' => true,
+			'length' => 255,
+		]);
+		$statusTable->addColumn('status_timestamp', Types::INTEGER, [
+			'notnull' => true,
+			'length' => 11,
+			'unsigned' => true,
+		]);
+		$statusTable->addColumn('is_user_defined', Types::BOOLEAN, [
+			'notnull' => true,
+		]);
+		$statusTable->addColumn('message_id', Types::STRING, [
+			'notnull' => false,
+			'length' => 255,
+		]);
+		$statusTable->addColumn('custom_icon', Types::STRING, [
+			'notnull' => false,
+			'length' => 255,
+		]);
+		$statusTable->addColumn('custom_message', Types::TEXT, [
+			'notnull' => false,
+		]);
+		$statusTable->addColumn('clear_at', Types::INTEGER, [
+			'notnull' => false,
+			'length' => 11,
+			'unsigned' => true,
+		]);
+
+		$statusTable->setPrimaryKey(['id']);
+		$statusTable->addUniqueIndex(['user_id'], 'user_status_uid_ix');
+		$statusTable->addIndex(['clear_at'], 'user_status_clr_ix');
+
+		return $schema;
+	}
+}

+ 100 - 0
apps/user_status/lib/Service/EmojiService.php

@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Service;
+
+use OCP\IDBConnection;
+
+/**
+ * Class EmojiService
+ *
+ * @package OCA\UserStatus\Service
+ */
+class EmojiService {
+
+	/** @var IDBConnection */
+	private $db;
+
+	/**
+	 * EmojiService constructor.
+	 *
+	 * @param IDBConnection $db
+	 */
+	public function __construct(IDBConnection $db) {
+		$this->db = $db;
+	}
+
+	/**
+	 * @return bool
+	 */
+	public function doesPlatformSupportEmoji(): bool {
+		return $this->db->supports4ByteText() &&
+			\class_exists(\IntlBreakIterator::class);
+	}
+
+	/**
+	 * @param string $emoji
+	 * @return bool
+	 */
+	public function isValidEmoji(string $emoji): bool {
+		$intlBreakIterator = \IntlBreakIterator::createCharacterInstance();
+		$intlBreakIterator->setText($emoji);
+
+		$characterCount = 0;
+		while ($intlBreakIterator->next() !== \IntlBreakIterator::DONE) {
+			$characterCount++;
+		}
+
+		if ($characterCount !== 1) {
+			return false;
+		}
+
+		$codePointIterator = \IntlBreakIterator::createCodePointInstance();
+		$codePointIterator->setText($emoji);
+
+		foreach ($codePointIterator->getPartsIterator() as $codePoint) {
+			$codePointType = \IntlChar::charType($codePoint);
+
+			// If the current code-point is an emoji or a modifier (like a skin-tone)
+			// just continue and check the next character
+			if ($codePointType === \IntlChar::CHAR_CATEGORY_MODIFIER_SYMBOL ||
+				$codePointType === \IntlChar::CHAR_CATEGORY_MODIFIER_LETTER ||
+				$codePointType === \IntlChar::CHAR_CATEGORY_OTHER_SYMBOL) {
+				continue;
+			}
+
+			// If it's neither a modifier nor an emoji, we only allow
+			// a zero-width-joiner or a variation selector 16
+			$codePointValue = \IntlChar::ord($codePoint);
+			if ($codePointValue === 8205 || $codePointValue === 65039) {
+				continue;
+			}
+
+			return false;
+		}
+
+		return true;
+	}
+}

+ 84 - 0
apps/user_status/lib/Service/JSDataService.php

@@ -0,0 +1,84 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Service;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IUserSession;
+
+class JSDataService implements \JsonSerializable {
+
+	/** @var IUserSession */
+	private $userSession;
+
+	/** @var StatusService */
+	private $statusService;
+
+	/**
+	 * JSDataService constructor.
+	 *
+	 * @param IUserSession $userSession
+	 * @param StatusService $statusService
+	 */
+	public function __construct(IUserSession $userSession,
+								StatusService $statusService) {
+		$this->userSession = $userSession;
+		$this->statusService = $statusService;
+	}
+
+	public function jsonSerialize() {
+		$user = $this->userSession->getUser();
+
+		if ($user === null) {
+			return [];
+		}
+
+		try {
+			$status = $this->statusService->findByUserId($user->getUID());
+		} catch (DoesNotExistException $ex) {
+			return [
+				'userId' => $user->getUID(),
+				'message' => null,
+				'messageId' => null,
+				'messageIsPredefined' => false,
+				'icon' => null,
+				'clearAt' => null,
+				'status' => 'offline',
+				'statusIsUserDefined' => false,
+			];
+		}
+
+		return [
+			'userId' => $status->getUserId(),
+			'message' => $status->getCustomMessage(),
+			'messageId' => $status->getMessageId(),
+			'messageIsPredefined' => $status->getMessageId() !== null,
+			'icon' => $status->getCustomIcon(),
+			'clearAt' => $status->getClearAt(),
+			'status' => $status->getStatus(),
+			'statusIsUserDefined' => $status->getIsUserDefined(),
+		];
+	}
+}

+ 187 - 0
apps/user_status/lib/Service/PredefinedStatusService.php

@@ -0,0 +1,187 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Service;
+
+use OCP\IL10N;
+
+/**
+ * Class DefaultStatusService
+ *
+ * We are offering a set of default statuses, so we can
+ * translate them into different languages.
+ *
+ * @package OCA\UserStatus\Service
+ */
+class PredefinedStatusService {
+	private const MEETING = 'meeting';
+	private const COMMUTING = 'commuting';
+	private const SICK_LEAVE = 'sick-leave';
+	private const VACATIONING = 'vacationing';
+	private const REMOTE_WORK = 'remote-work';
+
+	/** @var IL10N */
+	private $l10n;
+
+	/**
+	 * DefaultStatusService constructor.
+	 *
+	 * @param IL10N $l10n
+	 */
+	public function __construct(IL10N $l10n) {
+		$this->l10n = $l10n;
+	}
+
+	/**
+	 * @return array
+	 */
+	public function getDefaultStatuses(): array {
+		return [
+			[
+				'id' => self::MEETING,
+				'icon' => '📅',
+				'message' => $this->getTranslatedStatusForId(self::MEETING),
+				'clearAt' => [
+					'type' => 'period',
+					'time' => 3600,
+				],
+			],
+			[
+				'id' => self::COMMUTING,
+				'icon' => '🚌',
+				'message' => $this->getTranslatedStatusForId(self::COMMUTING),
+				'clearAt' => [
+					'type' => 'period',
+					'time' => 1800,
+				],
+			],
+			[
+				'id' => self::REMOTE_WORK,
+				'icon' => '🏡',
+				'message' => $this->getTranslatedStatusForId(self::REMOTE_WORK),
+				'clearAt' => [
+					'type' => 'end-of',
+					'time' => 'day',
+				],
+			],
+			[
+				'id' => self::SICK_LEAVE,
+				'icon' => '🤒',
+				'message' => $this->getTranslatedStatusForId(self::SICK_LEAVE),
+				'clearAt' => [
+					'type' => 'end-of',
+					'time' => 'day',
+				],
+			],
+			[
+				'id' => self::VACATIONING,
+				'icon' => '🌴',
+				'message' => $this->getTranslatedStatusForId(self::VACATIONING),
+				'clearAt' => null,
+			],
+		];
+	}
+
+	/**
+	 * @param string $id
+	 * @return array|null
+	 */
+	public function getDefaultStatusById(string $id): ?array {
+		foreach ($this->getDefaultStatuses() as $status) {
+			if ($status['id'] === $id) {
+				return $status;
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * @param string $id
+	 * @return string|null
+	 */
+	public function getIconForId(string $id): ?string {
+		switch ($id) {
+			case self::MEETING:
+				return '📅';
+
+			case self::COMMUTING:
+				return '🚌';
+
+			case self::SICK_LEAVE:
+				return '🤒';
+
+			case self::VACATIONING:
+				return '🌴';
+
+			case self::REMOTE_WORK:
+				return '🏡';
+
+			default:
+				return null;
+		}
+	}
+
+	/**
+	 * @param string $lang
+	 * @param string $id
+	 * @return string|null
+	 */
+	public function getTranslatedStatusForId(string $id): ?string {
+		switch ($id) {
+			case self::MEETING:
+				return $this->l10n->t('In a meeting');
+
+			case self::COMMUTING:
+				return $this->l10n->t('Commuting');
+
+			case self::SICK_LEAVE:
+				return $this->l10n->t('Out sick');
+
+			case self::VACATIONING:
+				return $this->l10n->t('Vacationing');
+
+			case self::REMOTE_WORK:
+				return $this->l10n->t('Working remotely');
+
+			default:
+				return null;
+		}
+	}
+
+	/**
+	 * @param string $id
+	 * @return bool
+	 */
+	public function isValidId(string $id): bool {
+		return \in_array($id, [
+			self::MEETING,
+			self::COMMUTING,
+			self::SICK_LEAVE,
+			self::VACATIONING,
+			self::REMOTE_WORK,
+		], true);
+	}
+}

+ 335 - 0
apps/user_status/lib/Service/StatusService.php

@@ -0,0 +1,335 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Service;
+
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Db\UserStatusMapper;
+use OCA\UserStatus\Exception\InvalidClearAtException;
+use OCA\UserStatus\Exception\InvalidMessageIdException;
+use OCA\UserStatus\Exception\InvalidStatusIconException;
+use OCA\UserStatus\Exception\InvalidStatusTypeException;
+use OCA\UserStatus\Exception\StatusMessageTooLongException;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Utility\ITimeFactory;
+
+/**
+ * Class StatusService
+ *
+ * @package OCA\UserStatus\Service
+ */
+class StatusService {
+
+	/** @var UserStatusMapper */
+	private $mapper;
+
+	/** @var ITimeFactory */
+	private $timeFactory;
+
+	/** @var PredefinedStatusService */
+	private $predefinedStatusService;
+
+	/** @var EmojiService */
+	private $emojiService;
+
+	/** @var string[] */
+	private $allowedStatusTypes = [
+		'online',
+		'away',
+		'dnd',
+		'invisible',
+		'offline'
+	];
+
+	/** @var int */
+	private $maximumMessageLength = 80;
+
+	/**
+	 * StatusService constructor.
+	 *
+	 * @param UserStatusMapper $mapper
+	 * @param ITimeFactory $timeFactory
+	 * @param PredefinedStatusService $defaultStatusService,
+	 * @param EmojiService $emojiService
+	 */
+	public function __construct(UserStatusMapper $mapper,
+								ITimeFactory $timeFactory,
+								PredefinedStatusService $defaultStatusService,
+								EmojiService $emojiService) {
+		$this->mapper = $mapper;
+		$this->timeFactory = $timeFactory;
+		$this->predefinedStatusService = $defaultStatusService;
+		$this->emojiService = $emojiService;
+	}
+
+	/**
+	 * @param int|null $limit
+	 * @param int|null $offset
+	 * @return UserStatus[]
+	 */
+	public function findAll(?int $limit = null, ?int $offset = null): array {
+		return array_map(function ($status) {
+			return $this->processStatus($status);
+		}, $this->mapper->findAll($limit, $offset));
+	}
+
+	/**
+	 * @param string $userId
+	 * @return UserStatus
+	 * @throws DoesNotExistException
+	 */
+	public function findByUserId(string $userId):UserStatus {
+		return $this->processStatus($this->mapper->findByUserId($userId));
+	}
+
+	/**
+	 * @param string $userId
+	 * @param string $status
+	 * @param int|null $statusTimestamp
+	 * @param bool $isUserDefined
+	 * @return UserStatus
+	 * @throws InvalidStatusTypeException
+	 */
+	public function setStatus(string $userId,
+							  string $status,
+							  ?int $statusTimestamp,
+							  bool $isUserDefined): UserStatus {
+		try {
+			$userStatus = $this->mapper->findByUserId($userId);
+		} catch (DoesNotExistException $ex) {
+			$userStatus = new UserStatus();
+			$userStatus->setUserId($userId);
+		}
+
+		// Check if status-type is valid
+		if (!\in_array($status, $this->allowedStatusTypes, true)) {
+			throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
+		}
+		if ($statusTimestamp === null) {
+			$statusTimestamp = $this->timeFactory->getTime();
+		}
+
+		$userStatus->setStatus($status);
+		$userStatus->setStatusTimestamp($statusTimestamp);
+		$userStatus->setIsUserDefined($isUserDefined);
+
+		if ($userStatus->getId() === null) {
+			return $this->mapper->insert($userStatus);
+		}
+
+		return $this->mapper->update($userStatus);
+	}
+
+	/**
+	 * @param string $userId
+	 * @param string $messageId
+	 * @param int|null $clearAt
+	 * @return UserStatus
+	 * @throws InvalidMessageIdException
+	 * @throws InvalidClearAtException
+	 */
+	public function setPredefinedMessage(string $userId,
+										 string $messageId,
+										 ?int $clearAt): UserStatus {
+		try {
+			$userStatus = $this->mapper->findByUserId($userId);
+		} catch (DoesNotExistException $ex) {
+			$userStatus = new UserStatus();
+			$userStatus->setUserId($userId);
+			$userStatus->setStatus('offline');
+			$userStatus->setStatusTimestamp(0);
+			$userStatus->setIsUserDefined(false);
+		}
+
+		if (!$this->predefinedStatusService->isValidId($messageId)) {
+			throw new InvalidMessageIdException('Message-Id "' . $messageId . '" is not supported');
+		}
+
+		// Check that clearAt is in the future
+		if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
+			throw new InvalidClearAtException('ClearAt is in the past');
+		}
+
+		$userStatus->setMessageId($messageId);
+		$userStatus->setCustomIcon(null);
+		$userStatus->setCustomMessage(null);
+		$userStatus->setClearAt($clearAt);
+
+		if ($userStatus->getId() === null) {
+			return $this->mapper->insert($userStatus);
+		}
+
+		return $this->mapper->update($userStatus);
+	}
+
+	/**
+	 * @param string $userId
+	 * @param string|null $statusIcon
+	 * @param string|null $message
+	 * @param int|null $clearAt
+	 * @return UserStatus
+	 * @throws InvalidClearAtException
+	 * @throws InvalidStatusIconException
+	 * @throws StatusMessageTooLongException
+	 */
+	public function setCustomMessage(string $userId,
+									 ?string $statusIcon,
+									 string $message,
+									 ?int $clearAt): UserStatus {
+		try {
+			$userStatus = $this->mapper->findByUserId($userId);
+		} catch (DoesNotExistException $ex) {
+			$userStatus = new UserStatus();
+			$userStatus->setUserId($userId);
+			$userStatus->setStatus('offline');
+			$userStatus->setStatusTimestamp(0);
+			$userStatus->setIsUserDefined(false);
+		}
+
+		// Check if statusIcon contains only one character
+		if ($statusIcon !== null && !$this->emojiService->isValidEmoji($statusIcon)) {
+			throw new InvalidStatusIconException('Status-Icon is longer than one character');
+		}
+		// Check for maximum length of custom message
+		if (\mb_strlen($message) > $this->maximumMessageLength) {
+			throw new StatusMessageTooLongException('Message is longer than supported length of ' . $this->maximumMessageLength . ' characters');
+		}
+		// Check that clearAt is in the future
+		if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
+			throw new InvalidClearAtException('ClearAt is in the past');
+		}
+
+		$userStatus->setMessageId(null);
+		$userStatus->setCustomIcon($statusIcon);
+		$userStatus->setCustomMessage($message);
+		$userStatus->setClearAt($clearAt);
+
+		if ($userStatus->getId() === null) {
+			return $this->mapper->insert($userStatus);
+		}
+
+		return $this->mapper->update($userStatus);
+	}
+
+	/**
+	 * @param string $userId
+	 * @return bool
+	 */
+	public function clearStatus(string $userId): bool {
+		try {
+			$userStatus = $this->mapper->findByUserId($userId);
+		} catch (DoesNotExistException $ex) {
+			// if there is no status to remove, just return
+			return false;
+		}
+
+		$userStatus->setStatus('offline');
+		$userStatus->setStatusTimestamp(0);
+		$userStatus->setIsUserDefined(false);
+
+		$this->mapper->update($userStatus);
+		return true;
+	}
+
+	/**
+	 * @param string $userId
+	 * @return bool
+	 */
+	public function clearMessage(string $userId): bool {
+		try {
+			$userStatus = $this->mapper->findByUserId($userId);
+		} catch (DoesNotExistException $ex) {
+			// if there is no status to remove, just return
+			return false;
+		}
+
+		$userStatus->setMessageId(null);
+		$userStatus->setCustomMessage(null);
+		$userStatus->setCustomIcon(null);
+		$userStatus->setClearAt(null);
+
+		$this->mapper->update($userStatus);
+		return true;
+	}
+
+	/**
+	 * @param string $userId
+	 * @return bool
+	 */
+	public function removeUserStatus(string $userId): bool {
+		try {
+			$userStatus = $this->mapper->findByUserId($userId);
+		} catch (DoesNotExistException $ex) {
+			// if there is no status to remove, just return
+			return false;
+		}
+
+		$this->mapper->delete($userStatus);
+		return true;
+	}
+
+	/**
+	 * Processes a status to check if custom message is still
+	 * up to date and provides translated default status if needed
+	 *
+	 * @param UserStatus $status
+	 * @returns UserStatus
+	 */
+	private function processStatus(UserStatus $status): UserStatus {
+		$clearAt = $status->getClearAt();
+		if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
+			$this->cleanStatus($status);
+		}
+		if ($status->getMessageId() !== null) {
+			$this->addDefaultMessage($status);
+		}
+
+		return $status;
+	}
+
+	/**
+	 * @param UserStatus $status
+	 */
+	private function cleanStatus(UserStatus $status): void {
+		$status->setMessageId(null);
+		$status->setCustomIcon(null);
+		$status->setCustomMessage(null);
+		$status->setClearAt(null);
+
+		$this->mapper->update($status);
+	}
+
+	/**
+	 * @param UserStatus $status
+	 */
+	private function addDefaultMessage(UserStatus $status): void {
+		// If the message is predefined, insert the translated message and icon
+		$predefinedMessage = $this->predefinedStatusService->getDefaultStatusById($status->getMessageId());
+		if ($predefinedMessage !== null) {
+			$status->setCustomMessage($predefinedMessage['message']);
+			$status->setCustomIcon($predefinedMessage['icon']);
+		}
+	}
+}

+ 271 - 0
apps/user_status/src/App.vue

@@ -0,0 +1,271 @@
+<!--
+  - @copyright Copyright (c) 2020 Georg Ehrke <oc.list@georgehrke.com>
+  - @author Georg Ehrke <oc.list@georgehrke.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<li>
+		<div id="user-status-menu-item">
+			<span id="user-status-menu-item__header">{{ displayName }}</span>
+			<Actions
+				id="user-status-menu-item__subheader"
+				:default-icon="statusIcon"
+				:menu-title="visibleMessage">
+				<ActionButton
+					v-for="status in statuses"
+					:key="status.type"
+					:icon="status.icon"
+					:close-after-click="true"
+					@click.prevent.stop="changeStatus(status.type)">
+					{{ status.label }}
+				</ActionButton>
+				<ActionButton
+					icon="icon-rename"
+					:close-after-click="true"
+					@click.prevent.stop="openModal">
+					{{ $t('user_status', 'Set custom status') }}
+				</ActionButton>
+			</Actions>
+			<SetStatusModal
+				v-if="isModalOpen"
+				@close="closeModal" />
+		</div>
+	</li>
+</template>
+
+<script>
+import { getCurrentUser } from '@nextcloud/auth'
+import SetStatusModal from './components/SetStatusModal'
+import Actions from '@nextcloud/vue/dist/Components/Actions'
+import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
+import { mapState } from 'vuex'
+import { showError } from '@nextcloud/dialogs'
+import { getAllStatusOptions } from './services/statusOptionsService'
+import { sendHeartbeat } from './services/heartbeatService'
+import debounce from 'debounce'
+
+export default {
+	name: 'App',
+	components: {
+		Actions,
+		ActionButton,
+		SetStatusModal,
+	},
+	data() {
+		return {
+			isModalOpen: false,
+			statuses: getAllStatusOptions(),
+			heartbeatInterval: null,
+			setAwayTimeout: null,
+			mouseMoveListener: null,
+			isAway: false,
+		}
+	},
+	computed: {
+		...mapState({
+			statusType: state => state.userStatus.status,
+			statusIsUserDefined: state => state.userStatus.statusIsUserDefined,
+			customIcon: state => state.userStatus.icon,
+			customMessage: state => state.userStatus.message,
+		}),
+		/**
+		 * The display-name of the current user
+		 *
+		 * @returns {String}
+		 */
+		displayName() {
+			return getCurrentUser().displayName
+		},
+		/**
+		 * The message displayed in the top right corner
+		 *
+		 * @returns {String}
+		 */
+		visibleMessage() {
+			if (this.customIcon && this.customMessage) {
+				return `${this.customIcon} ${this.customMessage}`
+			}
+			if (this.customMessage) {
+				return this.customMessage
+			}
+
+			if (this.statusIsUserDefined) {
+				switch (this.statusType) {
+				case 'online':
+					return this.$t('user_status', 'Online')
+
+				case 'away':
+					return this.$t('user_status', 'Away')
+
+				case 'dnd':
+					return this.$t('user_status', 'Do not disturb')
+
+				case 'invisible':
+					return this.$t('user_status', 'Invisible')
+
+				case 'offline':
+					return this.$t('user_status', 'Offline')
+				}
+			}
+
+			return this.$t('user_status', 'Set status')
+		},
+		/**
+		 * The status indicator icon
+		 *
+		 * @returns {String|null}
+		 */
+		statusIcon() {
+			switch (this.statusType) {
+			case 'online':
+				return 'icon-user-status-online'
+
+			case 'away':
+				return 'icon-user-status-away'
+
+			case 'dnd':
+				return 'icon-user-status-dnd'
+
+			case 'invisible':
+			case 'offline':
+				return 'icon-user-status-invisible'
+			}
+
+			return ''
+		},
+	},
+	/**
+	 * Loads the current user's status from initial state
+	 * and stores it in Vuex
+	 */
+	mounted() {
+		this.$store.dispatch('loadStatusFromInitialState')
+
+		if (OC.config.session_keepalive) {
+			// Send the latest status to the server every 5 minutes
+			this.heartbeatInterval = setInterval(this._backgroundHeartbeat.bind(this), 1000 * 60 * 5)
+			this.setAwayTimeout = () => {
+				this.isAway = true
+			}
+			// Catch mouse movements, but debounce to once every 30 seconds
+			this.mouseMoveListener = debounce(() => {
+				const wasAway = this.isAway
+				this.isAway = false
+				// Reset the two minute counter
+				clearTimeout(this.setAwayTimeout)
+				// If the user did not move the mouse within two minutes,
+				// mark them as away
+				setTimeout(this.setAwayTimeout, 1000 * 60 * 2)
+
+				if (wasAway) {
+					this._backgroundHeartbeat()
+				}
+			}, 1000 * 2, true)
+			window.addEventListener('mousemove', this.mouseMoveListener, {
+				capture: true,
+				passive: true,
+			})
+
+			this._backgroundHeartbeat()
+		}
+	},
+	/**
+	 * Some housekeeping before destroying the component
+	 */
+	beforeDestroy() {
+		window.removeEventListener('mouseMove', this.mouseMoveListener)
+		clearInterval(this.heartbeatInterval)
+	},
+	methods: {
+		/**
+		 * Opens the modal to set a custom status
+		 */
+		openModal() {
+			this.isModalOpen = true
+		},
+		/**
+		 * Closes the modal
+		 */
+		closeModal() {
+			this.isModalOpen = false
+		},
+		/**
+		 * Changes the user-status
+		 *
+		 * @param {String} statusType (online / away / dnd / invisible)
+		 */
+		async changeStatus(statusType) {
+			try {
+				await this.$store.dispatch('setStatus', { statusType })
+			} catch (err) {
+				showError(this.$t('user_status', 'There was an error saving the new status'))
+				console.debug(err)
+			}
+		},
+		/**
+		 * Sends the status heartbeat to the server
+		 *
+		 * @returns {Promise<void>}
+		 * @private
+		 */
+		async _backgroundHeartbeat() {
+			await sendHeartbeat(this.isAway)
+			await this.$store.dispatch('reFetchStatusFromServer')
+		},
+	},
+}
+</script>
+
+<style lang="scss">
+#user-status-menu-item {
+	&__header {
+		display: block;
+		align-items: center;
+		color: var(--color-main-text);
+		padding: 10px 12px 5px 12px;
+		box-sizing: border-box;
+		opacity: 1;
+		white-space: nowrap;
+		width: 100%;
+		text-align: center;
+		max-width: 250px;
+		text-overflow: ellipsis;
+		min-width: 175px;
+	}
+
+	&__subheader {
+		width: 100%;
+
+		> button {
+			background-color: var(--color-main-background);
+			background-size: 16px;
+			border: 0;
+			border-radius: 0;
+			font-weight: normal;
+			font-size: 0.875em;
+			padding-left: 40px;
+
+			&:hover,
+			&:focus {
+				box-shadow: inset 4px 0 var(--color-primary-element);
+			}
+		}
+	}
+}
+</style>

+ 102 - 0
apps/user_status/src/components/ClearAtSelect.vue

@@ -0,0 +1,102 @@
+<!--
+  - @copyright Copyright (c) 2020 Georg Ehrke <oc.list@georgehrke.com>
+  - @author Georg Ehrke <oc.list@georgehrke.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<div class="clear-at-select">
+		<span
+			class="clear-at-select__label">
+			{{ $t('user_select', 'Clear status after') }}
+		</span>
+		<Multiselect
+			label="label"
+			:value="option"
+			:options="options"
+			open-direction="top"
+			@select="select" />
+	</div>
+</template>
+
+<script>
+import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
+import { getAllClearAtOptions } from '../services/clearAtOptionsService'
+import { clearAtFilter } from '../filters/clearAtFilter'
+
+export default {
+	name: 'ClearAtSelect',
+	components: {
+		Multiselect,
+	},
+	props: {
+		clearAt: {
+			type: Object,
+			default: null,
+		},
+	},
+	data() {
+		return {
+			options: getAllClearAtOptions(),
+		}
+	},
+	computed: {
+		/**
+		 * Returns an object of the currently selected option
+		 *
+		 * @returns {Object}
+		 */
+		option() {
+			return {
+				clearAt: this.clearAt,
+				label: clearAtFilter(this.clearAt),
+			}
+		},
+	},
+	methods: {
+		/**
+		 * Triggered when the user selects a new option.
+		 *
+		 * @param {Object=} option The new selected option
+		 */
+		select(option) {
+			if (!option) {
+				return
+			}
+
+			this.$emit('selectClearAt', option.clearAt)
+		},
+	},
+}
+</script>
+
+<style lang="scss" scoped>
+.clear-at-select {
+	display: flex;
+	margin-bottom: 10px;
+	align-items: center;
+
+	&__label {
+		margin-right: 10px;
+	}
+
+	.multiselect {
+		flex-grow: 1;
+	}
+}
+</style>

+ 65 - 0
apps/user_status/src/components/CustomMessageInput.vue

@@ -0,0 +1,65 @@
+<!--
+  - @copyright Copyright (c) 2020 Georg Ehrke <oc.list@georgehrke.com>
+  - @author Georg Ehrke <oc.list@georgehrke.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+<template>
+	<form
+		class="custom-input__form"
+		@submit.prevent>
+		<input
+			:placeholder="$t('user_status', 'What\'s your status?')"
+			type="text"
+			:value="message"
+			@change="change">
+	</form>
+</template>
+
+<script>
+export default {
+	name: 'CustomMessageInput',
+	props: {
+		message: {
+			type: String,
+			required: true,
+			default: () => '',
+		},
+	},
+	methods: {
+		/**
+		 * Notifies the parent component about a changed input
+		 *
+		 * @param {Event} event The Change Event
+		 */
+		change(event) {
+			this.$emit('change', event.target.value)
+		},
+	},
+}
+</script>
+
+<style lang="scss" scoped>
+.custom-input__form {
+	flex-grow: 1;
+
+	input {
+		width: 100%;
+		border-radius: 0 var(--border-radius) var(--border-radius) 0;
+	}
+}
+</style>

+ 111 - 0
apps/user_status/src/components/PredefinedStatus.vue

@@ -0,0 +1,111 @@
+<!--
+  - @copyright Copyright (c) 2020 Georg Ehrke <oc.list@georgehrke.com>
+  - @author Georg Ehrke <oc.list@georgehrke.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+<template>
+	<div
+		class="predefined-status"
+		tabindex="0"
+		@keyup.enter="select"
+		@keyup.space="select"
+		@click="select">
+		<span class="predefined-status__icon">
+			{{ icon }}
+		</span>
+		<span class="predefined-status__message">
+			{{ message }}
+		</span>
+		<span class="predefined-status__clear-at">
+			{{ clearAt | clearAtFilter }}
+		</span>
+	</div>
+</template>
+
+<script>
+import { clearAtFilter } from '../filters/clearAtFilter'
+
+export default {
+	name: 'PredefinedStatus',
+	filters: {
+		clearAtFilter,
+	},
+	props: {
+		messageId: {
+			type: String,
+			required: true,
+		},
+		icon: {
+			type: String,
+			required: true,
+		},
+		message: {
+			type: String,
+			required: true,
+		},
+		clearAt: {
+			type: Object,
+			required: false,
+			default: null,
+		},
+	},
+	methods: {
+		/**
+		 * Emits an event when the user clicks the row
+		 */
+		select() {
+			this.$emit('select')
+		},
+	},
+}
+</script>
+
+<style lang="scss" scoped>
+.predefined-status {
+	display: flex;
+	flex-wrap: nowrap;
+	justify-content: flex-start;
+	flex-basis: 100%;
+	border-radius: var(--border-radius);
+	align-items: center;
+	min-height: 44px;
+
+	&:hover,
+	&:focus {
+		background-color: var(--color-background-hover);
+	}
+
+	&__icon {
+		flex-basis: 40px;
+		text-align: center;
+	}
+
+	&__message {
+		font-weight: bold;
+		padding: 0 6px;
+	}
+
+	&__clear-at {
+		opacity: .7;
+
+		&::before {
+			content: ' - ';
+		}
+	}
+}
+</style>

+ 90 - 0
apps/user_status/src/components/PredefinedStatusesList.vue

@@ -0,0 +1,90 @@
+<!--
+  - @copyright Copyright (c) 2020 Georg Ehrke <oc.list@georgehrke.com>
+  - @author Georg Ehrke <oc.list@georgehrke.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<div
+		v-if="hasLoaded"
+		class="predefined-statuses-list">
+		<PredefinedStatus
+			v-for="status in predefinedStatuses"
+			:key="status.id"
+			:message-id="status.id"
+			:icon="status.icon"
+			:message="status.message"
+			:clear-at="status.clearAt"
+			@select="selectStatus(status)" />
+	</div>
+	<div
+		v-else
+		class="predefined-statuses-list">
+		<div class="icon icon-loading-small" />
+	</div>
+</template>
+
+<script>
+import PredefinedStatus from './PredefinedStatus'
+import { mapState } from 'vuex'
+
+export default {
+	name: 'PredefinedStatusesList',
+	components: {
+		PredefinedStatus,
+	},
+	computed: {
+		...mapState({
+			predefinedStatuses: state => state.predefinedStatuses.predefinedStatuses,
+		}),
+		/**
+		 * Indicator whether the predefined statuses have already been loaded
+		 *
+		 * @returns {boolean}
+		 */
+		hasLoaded() {
+			return this.predefinedStatuses.length > 0
+		},
+	},
+	/**
+	 * Loads all predefined statuses from the server
+	 * when this component is mounted
+	 */
+	mounted() {
+		this.$store.dispatch('loadAllPredefinedStatuses')
+	},
+	methods: {
+		/**
+		 * Emits an event when the user selects a status
+		 *
+		 * @param {Object} status The selected status
+		 */
+		selectStatus(status) {
+			this.$emit('selectStatus', status)
+		},
+	},
+}
+</script>
+
+<style lang="scss" scoped>
+.predefined-statuses-list {
+	display: flex;
+	flex-direction: column;
+	margin-bottom: 10px;
+}
+</style>

+ 236 - 0
apps/user_status/src/components/SetStatusModal.vue

@@ -0,0 +1,236 @@
+<!--
+  - @copyright Copyright (c) 2020 Georg Ehrke <oc.list@georgehrke.com>
+  - @author Georg Ehrke <oc.list@georgehrke.com>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+
+<template>
+	<Modal
+		size="normal"
+		:title="$t('user_status', 'Set a custom status')"
+		@close="closeModal">
+		<div class="set-status-modal">
+			<div class="set-status-modal__header">
+				<h3>{{ $t('user_status', 'Set a custom status') }}</h3>
+			</div>
+			<div class="set-status-modal__custom-input">
+				<EmojiPicker @select="setIcon">
+					<button
+						class="custom-input__emoji-button">
+						{{ visibleIcon }}
+					</button>
+				</EmojiPicker>
+				<CustomMessageInput
+					:message="message"
+					@change="setMessage" />
+			</div>
+			<PredefinedStatusesList
+				@selectStatus="selectPredefinedMessage" />
+			<ClearAtSelect
+				:clear-at="clearAt"
+				@selectClearAt="setClearAt" />
+			<div class="status-buttons">
+				<button class="status-buttons__select" @click="clearStatus">
+					{{ $t('user_status', 'Clear custom status') }}
+				</button>
+				<button class="status-buttons__primary primary" @click="saveStatus">
+					{{ $t('user_status', 'Set status') }}
+				</button>
+			</div>
+		</div>
+	</Modal>
+</template>
+
+<script>
+import EmojiPicker from '@nextcloud/vue/dist/Components/EmojiPicker'
+import Modal from '@nextcloud/vue/dist/Components/Modal'
+import PredefinedStatusesList from './PredefinedStatusesList'
+import CustomMessageInput from './CustomMessageInput'
+import ClearAtSelect from './ClearAtSelect'
+import { showError } from '@nextcloud/dialogs'
+
+export default {
+	name: 'SetStatusModal',
+	components: {
+		EmojiPicker,
+		Modal,
+		CustomMessageInput,
+		PredefinedStatusesList,
+		ClearAtSelect,
+	},
+	data() {
+		return {
+			icon: null,
+			message: null,
+			clearAt: null,
+		}
+	},
+	computed: {
+		/**
+		 * Returns the user-set icon or a smiley in case no icon is set
+		 *
+		 * @returns {String}
+		 */
+		visibleIcon() {
+			return this.icon || '😀'
+		},
+	},
+	/**
+	 * Loads the current status when a user opens dialog
+	 */
+	mounted() {
+		this.messageId = this.$store.state.userStatus.messageId
+		this.icon = this.$store.state.userStatus.icon
+		this.message = this.$store.state.userStatus.message
+
+		if (this.$store.state.userStatus.clearAt !== null) {
+			this.clearAt = {
+				type: '_time',
+				time: this.$store.state.userStatus.clearAt,
+			}
+		}
+	},
+	methods: {
+		/**
+		 * Closes the Set Status modal
+		 */
+		closeModal() {
+			this.$emit('close')
+		},
+		/**
+		 * Sets a new icon
+		 *
+		 * @param {String} icon The new icon
+		 */
+		setIcon(icon) {
+			this.messageId = null
+			this.icon = icon
+		},
+		/**
+		 * Sets a new message
+		 *
+		 * @param {String} message The new message
+		 */
+		setMessage(message) {
+			this.messageId = null
+			this.message = message
+		},
+		/**
+		 * Sets a new clearAt value
+		 *
+		 * @param {Object} clearAt The new clearAt object
+		 */
+		setClearAt(clearAt) {
+			this.clearAt = clearAt
+		},
+		/**
+		 * Sets new icon/message/clearAt based on a predefined message
+		 *
+		 * @param {Object} status The predefined status object
+		 */
+		selectPredefinedMessage(status) {
+			this.messageId = status.id
+			this.clearAt = status.clearAt
+			this.icon = status.icon
+			this.message = status.message
+		},
+		/**
+		 * Saves the status and closes the
+		 *
+		 * @returns {Promise<void>}
+		 */
+		async saveStatus() {
+			try {
+				this.isSavingStatus = true
+
+				if (this.messageId !== null) {
+					await this.$store.dispatch('setPredefinedMessage', {
+						messageId: this.messageId,
+						clearAt: this.clearAt,
+					})
+				} else {
+					await this.$store.dispatch('setCustomMessage', {
+						message: this.message,
+						icon: this.icon,
+						clearAt: this.clearAt,
+					})
+				}
+			} catch (err) {
+				showError(this.$t('user_status', 'There was an error saving the status'))
+				console.debug(err)
+				this.isSavingStatus = false
+				return
+			}
+
+			this.isSavingStatus = false
+			this.closeModal()
+		},
+		/**
+		 *
+		 * @returns {Promise<void>}
+		 */
+		async clearStatus() {
+			try {
+				this.isSavingStatus = true
+
+				await this.$store.dispatch('clearMessage')
+			} catch (err) {
+				showError(this.$t('user_status', 'There was an error clearing the status'))
+				console.debug(err)
+				this.isSavingStatus = false
+				return
+			}
+
+			this.isSavingStatus = false
+			this.closeModal()
+		},
+	},
+}
+</script>
+
+<style lang="scss" scoped>
+.set-status-modal {
+	min-width: 500px;
+	min-height: 200px;
+	padding: 8px 20px 20px 20px;
+
+	&__custom-input {
+		display: flex;
+		width: 100%;
+		margin-bottom: 10px;
+
+		.custom-input__emoji-button {
+			flex-basis: 40px;
+			width: 40px;
+			flex-grow: 0;
+			border-radius: var(--border-radius) 0 0 var(--border-radius);
+			height: 34px;
+			margin-right: 0;
+			border-right: none;
+		}
+	}
+
+	.status-buttons {
+		display: flex;
+
+		button {
+			flex-basis: 50%;
+		}
+	}
+}
+</style>

+ 68 - 0
apps/user_status/src/filters/clearAtFilter.js

@@ -0,0 +1,68 @@
+/**
+ * @copyright Copyright (c) 2020 Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import { translate as t } from '@nextcloud/l10n'
+import moment from '@nextcloud/moment'
+import { dateFactory } from '../services/dateService'
+
+/**
+ * Formats a clearAt object to be human readable
+ *
+ * @param {Object} clearAt The clearAt object
+ * @returns {string|null}
+ */
+const clearAtFilter = (clearAt) => {
+	if (clearAt === null) {
+		return t('user_status', 'Don\'t clear')
+	}
+
+	if (clearAt.type === 'end-of') {
+		switch (clearAt.time) {
+		case 'day':
+			return t('user_status', 'Today')
+		case 'week':
+			return t('user_status', 'This week')
+
+		default:
+			return null
+		}
+	}
+
+	if (clearAt.type === 'period') {
+		return moment.duration(clearAt.time * 1000).humanize()
+	}
+
+	// This is not an officially supported type
+	// but only used internally to show the remaining time
+	// in the Set Status Modal
+	if (clearAt.type === '_time') {
+		const momentNow = moment(dateFactory())
+		const momentClearAt = moment(clearAt.time, 'X')
+
+		return moment.duration(momentNow.diff(momentClearAt)).humanize()
+	}
+
+	return null
+}
+
+export {
+	clearAtFilter,
+}

+ 23 - 0
apps/user_status/src/main-user-status-menu.js

@@ -0,0 +1,23 @@
+import Vue from 'vue'
+import { getRequestToken } from '@nextcloud/auth'
+import App from './App'
+import store from './store'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = btoa(getRequestToken())
+
+// Correct the root of the app for chunk loading
+// OC.linkTo matches the apps folders
+// OC.generateUrl ensure the index.php (or not)
+// eslint-disable-next-line
+__webpack_public_path__ = OC.linkTo('user_status', 'js/')
+
+Vue.prototype.t = t
+Vue.prototype.$t = t
+
+const app = new Vue({
+	render: h => h(App),
+	store,
+}).$mount('li[data-id="user_status-menuitem"]')
+
+export { app }

+ 68 - 0
apps/user_status/src/services/clearAtOptionsService.js

@@ -0,0 +1,68 @@
+/**
+ * @copyright Copyright (c) 2020 Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import { translate as t } from '@nextcloud/l10n'
+
+/**
+ * Returns an array
+ *
+ * @returns {Object[]}
+ */
+const getAllClearAtOptions = () => {
+	return [{
+		label: t('user_status', 'Don\'t clear'),
+		clearAt: null,
+	}, {
+		label: t('user_status', '30 minutes'),
+		clearAt: {
+			type: 'period',
+			time: 1800,
+		},
+	}, {
+		label: t('user_status', '1 hour'),
+		clearAt: {
+			type: 'period',
+			time: 3600,
+		},
+	}, {
+		label: t('user_status', '4 hours'),
+		clearAt: {
+			type: 'period',
+			time: 14400,
+		},
+	}, {
+		label: t('user_status', 'Today'),
+		clearAt: {
+			type: 'end-of',
+			time: 'day',
+		},
+	}, {
+		label: t('user_status', 'This week'),
+		clearAt: {
+			type: 'end-of',
+			time: 'week',
+		},
+	}]
+}
+
+export {
+	getAllClearAtOptions,
+}

+ 63 - 0
apps/user_status/src/services/clearAtService.js

@@ -0,0 +1,63 @@
+/**
+ * @copyright Copyright (c) 2020 Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import {
+	dateFactory,
+} from './dateService'
+import moment from '@nextcloud/moment'
+
+/**
+ * Calculates the actual clearAt timestamp
+ *
+ * @param {Object|null} clearAt The clear-at config
+ * @returns {Number|null}
+ */
+const getTimestampForClearAt = (clearAt) => {
+	if (clearAt === null) {
+		return null
+	}
+
+	const date = dateFactory()
+
+	if (clearAt.type === 'period') {
+		date.setSeconds(date.getSeconds() + clearAt.time)
+		return Math.floor(date.getTime() / 1000)
+	}
+	if (clearAt.type === 'end-of') {
+		switch (clearAt.time) {
+		case 'day':
+		case 'week':
+			return Number(moment(date).endOf(clearAt.time).format('X'))
+		}
+	}
+	// This is not an officially supported type
+	// but only used internally to show the remaining time
+	// in the Set Status Modal
+	if (clearAt.type === '_time') {
+		return clearAt.time
+	}
+
+	return null
+}
+
+export {
+	getTimestampForClearAt,
+}

+ 34 - 0
apps/user_status/src/services/dateService.js

@@ -0,0 +1,34 @@
+/**
+ * @copyright Copyright (c) 2020 Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+/**
+ * Returns a new Date object
+ *
+ * @returns {Date}
+ */
+const dateFactory = () => {
+	return new Date()
+}
+
+export {
+	dateFactory,
+}

+ 40 - 0
apps/user_status/src/services/heartbeatService.js

@@ -0,0 +1,40 @@
+/**
+ * @copyright Copyright (c) 2020 Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import HttpClient from '@nextcloud/axios'
+import { generateUrl } from '@nextcloud/router'
+
+/**
+ * Sends a heartbeat
+ *
+ * @param {Boolean} isAway Whether or not the user is active
+ * @returns {Promise<void>}
+ */
+const sendHeartbeat = async(isAway) => {
+	const url = generateUrl('/apps/user_status/heartbeat')
+	await HttpClient.put(url, {
+		status: isAway ? 'away' : 'online',
+	})
+}
+
+export {
+	sendHeartbeat,
+}

+ 39 - 0
apps/user_status/src/services/predefinedStatusService.js

@@ -0,0 +1,39 @@
+/**
+ * @copyright Copyright (c) 2020 Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import HttpClient from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+
+/**
+ * Fetches all predefined statuses from the server
+ *
+ * @returns {Promise<void>}
+ */
+const fetchAllPredefinedStatuses = async() => {
+	const url = generateOcsUrl('apps/user_status/api/v1', 2) + '/predefined_statuses?format=json'
+	const response = await HttpClient.get(url)
+
+	return response.data.ocs.data
+}
+
+export {
+	fetchAllPredefinedStatuses,
+}

+ 52 - 0
apps/user_status/src/services/statusOptionsService.js

@@ -0,0 +1,52 @@
+/**
+ * @copyright Copyright (c) 2020 Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import { translate as t } from '@nextcloud/l10n'
+
+/**
+ * Returns a list of all user-definable statuses
+ *
+ * @returns {Object[]}
+ */
+const getAllStatusOptions = () => {
+	return [{
+		type: 'online',
+		label: t('user_status', 'Online'),
+		icon: 'icon-user-status-online',
+	}, {
+		type: 'away',
+		label: t('user_status', 'Away'),
+		icon: 'icon-user-status-away',
+	}, {
+		type: 'dnd',
+		label: t('user_status', 'Do not disturb'),
+		icon: 'icon-user-status-dnd',
+
+	}, {
+		type: 'invisible',
+		label: t('user_status', 'Invisible'),
+		icon: 'icon-user-status-invisible',
+	}]
+}
+
+export {
+	getAllStatusOptions,
+}

+ 98 - 0
apps/user_status/src/services/statusService.js

@@ -0,0 +1,98 @@
+/**
+ * @copyright Copyright (c) 2020 Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import HttpClient from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+
+/**
+ * Fetches the current user-status
+ *
+ * @returns {Promise<Object>}
+ */
+const fetchCurrentStatus = async() => {
+	const url = generateOcsUrl('apps/user_status/api/v1', 2) + 'user_status'
+	const response = await HttpClient.get(url)
+
+	return response.data.ocs.data
+}
+
+/**
+ * Sets the status
+ *
+ * @param {String} statusType The status (online / away / dnd / invisible)
+ * @returns {Promise<void>}
+ */
+const setStatus = async(statusType) => {
+	const url = generateOcsUrl('apps/user_status/api/v1', 2) + 'user_status/status'
+	await HttpClient.put(url, {
+		statusType,
+	})
+}
+
+/**
+ * Sets a message based on our predefined statuses
+ *
+ * @param {String} messageId The id of the message, taken from predefined status service
+ * @param {Number|null} clearAt When to automatically clean the status
+ * @returns {Promise<void>}
+ */
+const setPredefinedMessage = async(messageId, clearAt = null) => {
+	const url = generateOcsUrl('apps/user_status/api/v1', 2) + 'user_status/message/predefined?format=json'
+	await HttpClient.put(url, {
+		messageId,
+		clearAt,
+	})
+}
+
+/**
+ * Sets a custom message
+ *
+ * @param {String} message The user-defined message
+ * @param {String|null} statusIcon The user-defined icon
+ * @param {Number|null} clearAt When to automatically clean the status
+ * @returns {Promise<void>}
+ */
+const setCustomMessage = async(message, statusIcon = null, clearAt = null) => {
+	const url = generateOcsUrl('apps/user_status/api/v1', 2) + 'user_status/message/custom?format=json'
+	await HttpClient.put(url, {
+		message,
+		statusIcon,
+		clearAt,
+	})
+}
+
+/**
+ * Clears the current status of the user
+ *
+ * @returns {Promise<void>}
+ */
+const clearMessage = async() => {
+	const url = generateOcsUrl('apps/user_status/api/v1', 2) + 'user_status/message?format=json'
+	await HttpClient.delete(url)
+}
+
+export {
+	fetchCurrentStatus,
+	setStatus,
+	setCustomMessage,
+	setPredefinedMessage,
+	clearMessage,
+}

+ 35 - 0
apps/user_status/src/store/index.js

@@ -0,0 +1,35 @@
+/**
+ * @copyright Copyright (c) 2020 Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import Vue from 'vue'
+import Vuex from 'vuex'
+import predefinedStatuses from './predefinedStatuses'
+import userStatus from './userStatus'
+
+Vue.use(Vuex)
+
+export default new Vuex.Store({
+	modules: {
+		predefinedStatuses,
+		userStatus,
+	},
+	strict: true,
+})

+ 64 - 0
apps/user_status/src/store/predefinedStatuses.js

@@ -0,0 +1,64 @@
+/**
+ * @copyright Copyright (c) 2020 Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import { fetchAllPredefinedStatuses } from '../services/predefinedStatusService'
+
+const state = {
+	predefinedStatuses: [],
+}
+
+const mutations = {
+
+	/**
+	 * Adds a predefined status to the state
+	 *
+	 * @param {Object} state The Vuex state
+	 * @param {Object} status The status to add
+	 */
+	addPredefinedStatus(state, status) {
+		state.predefinedStatuses.push(status)
+	},
+}
+
+const getters = {}
+
+const actions = {
+
+	/**
+	 * Loads all predefined statuses from the server
+	 *
+	 * @param {Object} vuex The Vuex components
+	 * @param {Function} vuex.commit The Vuex commit function
+	 */
+	async loadAllPredefinedStatuses({ state, commit }) {
+		if (state.predefinedStatuses.length > 0) {
+			return
+		}
+
+		const statuses = await fetchAllPredefinedStatuses()
+		for (const status of statuses) {
+			commit('addPredefinedStatus', status)
+		}
+	},
+
+}
+
+export default { state, mutations, getters, actions }

+ 232 - 0
apps/user_status/src/store/userStatus.js

@@ -0,0 +1,232 @@
+/**
+ * @copyright Copyright (c) 2020 Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import {
+	fetchCurrentStatus,
+	setStatus,
+	setPredefinedMessage,
+	setCustomMessage,
+	clearMessage,
+} from '../services/statusService'
+import { loadState } from '@nextcloud/initial-state'
+import { getTimestampForClearAt } from '../services/clearAtService'
+
+const state = {
+	// Status (online / away / dnd / invisible / offline)
+	status: null,
+	// Whether or not the status is user-defined
+	statusIsUserDefined: null,
+	// A custom message set by the user
+	message: null,
+	// The icon selected by the user
+	icon: null,
+	// When to automatically clean the status
+	clearAt: null,
+	// Whether or not the message is predefined
+	// (and can automatically be translated by Nextcloud)
+	messageIsPredefined: null,
+	// The id of the message in case it's predefined
+	messageId: null,
+}
+
+const mutations = {
+
+	/**
+	 * Sets a new status
+	 *
+	 * @param {Object} state The Vuex state
+	 * @param {Object} data The destructuring object
+	 * @param {String} data.statusType The new status type
+	 */
+	setStatus(state, { statusType }) {
+		state.status = statusType
+		state.statusIsUserDefined = true
+	},
+
+	/**
+	 * Sets a message using a predefined message
+	 *
+	 * @param {Object} state The Vuex state
+	 * @param {Object} data The destructuring object
+	 * @param {String} data.messageId The messageId
+	 * @param {Number|null} data.clearAt When to automatically clear the status
+	 * @param {String} data.message The message
+	 * @param {String} data.icon The icon
+	 */
+	setPredefinedMessage(state, { messageId, clearAt, message, icon }) {
+		state.messageId = messageId
+		state.messageIsPredefined = true
+
+		state.message = message
+		state.icon = icon
+		state.clearAt = clearAt
+	},
+
+	/**
+	 * Sets a custom message
+	 *
+	 * @param {Object} state The Vuex state
+	 * @param {Object} data The destructuring object
+	 * @param {String} data.message The message
+	 * @param {String} data.icon The icon
+	 * @param {Number} data.clearAt When to automatically clear the status
+	 */
+	setCustomMessage(state, { message, icon, clearAt }) {
+		state.messageId = null
+		state.messageIsPredefined = false
+
+		state.message = message
+		state.icon = icon
+		state.clearAt = clearAt
+	},
+
+	/**
+	 * Clears the status
+	 *
+	 * @param {Object} state The Vuex state
+	 */
+	clearMessage(state) {
+		state.messageId = null
+		state.messageIsPredefined = false
+
+		state.message = null
+		state.icon = null
+		state.clearAt = null
+	},
+
+	/**
+	 * Loads the status from initial state
+	 *
+	 * @param {Object} state The Vuex state
+	 * @param {Object} data The destructuring object
+	 * @param {String} data.status The status type
+	 * @param {Boolean} data.statusIsUserDefined Whether or not this status is user-defined
+	 * @param {String} data.message The message
+	 * @param {String} data.icon The icon
+	 * @param {Number} data.clearAt When to automatically clear the status
+	 * @param {Boolean} data.messageIsPredefined Whether or not the message is predefined
+	 * @param {string} data.messageId The id of the predefined message
+	 */
+	loadStatusFromServer(state, { status, statusIsUserDefined, message, icon, clearAt, messageIsPredefined, messageId }) {
+		state.status = status
+		state.statusIsUserDefined = statusIsUserDefined
+		state.message = message
+		state.icon = icon
+		state.clearAt = clearAt
+		state.messageIsPredefined = messageIsPredefined
+		state.messageId = messageId
+	},
+}
+
+const getters = {}
+
+const actions = {
+
+	/**
+	 * Sets a new status
+	 *
+	 * @param {Object} vuex The Vuex destructuring object
+	 * @param {Function} vuex.commit The Vuex commit function
+	 * @param {Object} data The data destructuring object
+	 * @param {String} data.statusType The new status type
+	 * @returns {Promise<void>}
+	 */
+	async setStatus({ commit }, { statusType }) {
+		await setStatus(statusType)
+		commit('setStatus', { statusType })
+	},
+
+	/**
+	 * Sets a message using a predefined message
+	 *
+	 * @param {Object} vuex The Vuex destructuring object
+	 * @param {Function} vuex.commit The Vuex commit function
+	 * @param {Object} vuex.rootState The Vuex root state
+	 * @param {Object} data The data destructuring object
+	 * @param {String} data.messageId The messageId
+	 * @param {Object|null} data.clearAt When to automatically clear the status
+	 * @returns {Promise<void>}
+	 */
+	async setPredefinedMessage({ commit, rootState }, { messageId, clearAt }) {
+		const resolvedClearAt = getTimestampForClearAt(clearAt)
+
+		await setPredefinedMessage(messageId, resolvedClearAt)
+		const status = rootState.predefinedStatuses.predefinedStatuses.find((status) => status.id === messageId)
+		const { message, icon } = status
+
+		commit('setPredefinedMessage', { messageId, clearAt: resolvedClearAt, message, icon })
+	},
+
+	/**
+	 * Sets a custom message
+	 *
+	 * @param {Object} vuex The Vuex destructuring object
+	 * @param {Function} vuex.commit The Vuex commit function
+	 * @param {Object} data The data destructuring object
+	 * @param {String} data.message The message
+	 * @param {String} data.icon The icon
+	 * @param {Object|null} data.clearAt When to automatically clear the status
+	 * @returns {Promise<void>}
+	 */
+	async setCustomMessage({ commit }, { message, icon, clearAt }) {
+		const resolvedClearAt = getTimestampForClearAt(clearAt)
+
+		await setCustomMessage(message, icon, resolvedClearAt)
+		commit('setCustomMessage', { message, icon, clearAt: resolvedClearAt })
+	},
+
+	/**
+	 * Clears the status
+	 *
+	 * @param {Object} vuex The Vuex destructuring object
+	 * @param {Function} vuex.commit The Vuex commit function
+	 * @returns {Promise<void>}
+	 */
+	async clearMessage({ commit }) {
+		await clearMessage()
+		commit('clearMessage')
+	},
+
+	/**
+	 * Re-fetches the status from the server
+	 *
+	 * @param {Object} vuex The Vuex destructuring object
+	 * @param {Function} vuex.commit The Vuex commit function
+	 * @returns {Promise<void>}
+	 */
+	async reFetchStatusFromServer({ commit }) {
+		const status = await fetchCurrentStatus()
+		commit('loadStatusFromServer', status)
+	},
+
+	/**
+	 * Loads the server from the initial state
+	 *
+	 * @param {Object} vuex The Vuex destructuring object
+	 * @param {Function} vuex.commit The Vuex commit function
+	 */
+	loadStatusFromInitialState({ commit }) {
+		const status = loadState('user_status', 'status')
+		commit('loadStatusFromServer', status)
+	},
+}
+
+export default { state, mutations, getters, actions }

+ 63 - 0
apps/user_status/tests/Unit/BackgroundJob/ClearOldStatusesBackgroundJobTest.php

@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Tests\BackgroundJob;
+
+use OCA\UserStatus\BackgroundJob\ClearOldStatusesBackgroundJob;
+use OCA\UserStatus\Db\UserStatusMapper;
+use OCP\AppFramework\Utility\ITimeFactory;
+use Test\TestCase;
+
+class ClearOldStatusesBackgroundJobTest extends TestCase {
+
+	/** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */
+	private $time;
+
+	/** @var UserStatusMapper|\PHPUnit\Framework\MockObject\MockObject */
+	private $mapper;
+
+	/** @var ClearOldStatusesBackgroundJob */
+	private $job;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->time = $this->createMock(ITimeFactory::class);
+		$this->mapper = $this->createMock(UserStatusMapper::class);
+
+		$this->job = new ClearOldStatusesBackgroundJob($this->time, $this->mapper);
+	}
+
+	public function testRun() {
+		$this->mapper->expects($this->once())
+			->method('clearOlderThan')
+			->with(1337);
+
+		$this->time->method('getTime')
+			->willReturn(1337);
+
+		self::invokePrivate($this->job, 'run', [[]]);
+	}
+}

+ 71 - 0
apps/user_status/tests/Unit/CapabilitiesTest.php

@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Tests;
+
+use OCA\UserStatus\Capabilities;
+use OCA\UserStatus\Service\EmojiService;
+use Test\TestCase;
+
+class CapabilitiesTest extends TestCase {
+
+	/** @var EmojiService|\PHPUnit\Framework\MockObject\MockObject */
+	private $emojiService;
+
+	/** @var Capabilities */
+	private $capabilities;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->emojiService = $this->createMock(EmojiService::class);
+		$this->capabilities = new Capabilities($this->emojiService);
+	}
+
+	/**
+	 * @param bool $supportsEmojis
+	 *
+	 * @dataProvider getCapabilitiesDataProvider
+	 */
+	public function testGetCapabilities(bool $supportsEmojis): void {
+		$this->emojiService->expects($this->once())
+			->method('doesPlatformSupportEmoji')
+			->willReturn($supportsEmojis);
+
+		$this->assertEquals([
+			'user_status' => [
+				'enabled' => true,
+				'supports_emoji' => $supportsEmojis,
+			]
+		], $this->capabilities->getCapabilities());
+	}
+
+	public function getCapabilitiesDataProvider(): array {
+		return [
+			[true],
+			[false],
+		];
+	}
+}

+ 74 - 0
apps/user_status/tests/Unit/Controller/PredefinedStatusControllerTest.php

@@ -0,0 +1,74 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Tests\Controller;
+
+use OCA\UserStatus\Controller\PredefinedStatusController;
+use OCA\UserStatus\Service\PredefinedStatusService;
+use OCP\IRequest;
+use Test\TestCase;
+
+class PredefinedStatusControllerTest extends TestCase {
+
+	/** @var PredefinedStatusService|\PHPUnit\Framework\MockObject\MockObject */
+	private $service;
+
+	/** @var PredefinedStatusController */
+	private $controller;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$request = $this->createMock(IRequest::class);
+		$this->service = $this->createMock(PredefinedStatusService::class);
+
+		$this->controller = new PredefinedStatusController('user_status', $request,
+			$this->service);
+	}
+
+	public function testFindAll() {
+		$this->service->expects($this->once())
+			->method('getDefaultStatuses')
+			->with()
+			->willReturn([
+				[
+					'id' => 'predefined-status-one',
+				],
+				[
+					'id' => 'predefined-status-two',
+				],
+			]);
+
+		$actual = $this->controller->findAll();
+		$this->assertEquals([
+			[
+				'id' => 'predefined-status-one',
+			],
+			[
+				'id' => 'predefined-status-two',
+			],
+		], $actual->getData());
+	}
+}

+ 114 - 0
apps/user_status/tests/Unit/Controller/StatusesControllerTest.php

@@ -0,0 +1,114 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Tests\Controller;
+
+use OCA\UserStatus\Controller\StatusesController;
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Service\StatusService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\OCS\OCSNotFoundException;
+use OCP\IRequest;
+use Test\TestCase;
+
+class StatusesControllerTest extends TestCase {
+
+	/** @var StatusService|\PHPUnit\Framework\MockObject\MockObject */
+	private $service;
+
+	/** @var StatusesController */
+	private $controller;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$request = $this->createMock(IRequest::class);
+		$this->service = $this->createMock(StatusService::class);
+
+		$this->controller = new StatusesController('user_status', $request, $this->service);
+	}
+
+	public function testFindAll(): void {
+		$userStatus = $this->getUserStatus();
+
+		$this->service->expects($this->once())
+			->method('findAll')
+			->with(20, 40)
+			->willReturn([$userStatus]);
+
+		$response = $this->controller->findAll(20, 40);
+		$this->assertEquals([[
+			'userId' => 'john.doe',
+			'status' => 'offline',
+			'icon' => '🏝',
+			'message' => 'On vacation',
+			'clearAt' => 60000,
+		]], $response->getData());
+	}
+
+	public function testFind(): void {
+		$userStatus = $this->getUserStatus();
+
+		$this->service->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willReturn($userStatus);
+
+		$response = $this->controller->find('john.doe');
+		$this->assertEquals([
+			'userId' => 'john.doe',
+			'status' => 'offline',
+			'icon' => '🏝',
+			'message' => 'On vacation',
+			'clearAt' => 60000,
+		], $response->getData());
+	}
+
+	public function testFindDoesNotExist(): void {
+		$this->service->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willThrowException(new DoesNotExistException(''));
+
+		$this->expectException(OCSNotFoundException::class);
+		$this->expectExceptionMessage('No status for the requested userId');
+
+		$this->controller->find('john.doe');
+	}
+
+	private function getUserStatus(): UserStatus {
+		$userStatus = new UserStatus();
+		$userStatus->setId(1337);
+		$userStatus->setUserId('john.doe');
+		$userStatus->setStatus('invisible');
+		$userStatus->setStatusTimestamp(5000);
+		$userStatus->setIsUserDefined(true);
+		$userStatus->setCustomIcon('🏝');
+		$userStatus->setCustomMessage('On vacation');
+		$userStatus->setClearAt(60000);
+
+		return $userStatus;
+	}
+}

+ 340 - 0
apps/user_status/tests/Unit/Controller/UserStatusControllerTest.php

@@ -0,0 +1,340 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Tests\Controller;
+
+use OCA\UserStatus\Controller\UserStatusController;
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Exception\InvalidClearAtException;
+use OCA\UserStatus\Exception\InvalidMessageIdException;
+use OCA\UserStatus\Exception\InvalidStatusIconException;
+use OCA\UserStatus\Exception\InvalidStatusTypeException;
+use OCA\UserStatus\Exception\StatusMessageTooLongException;
+use OCA\UserStatus\Service\StatusService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\OCS\OCSBadRequestException;
+use OCP\AppFramework\OCS\OCSNotFoundException;
+use OCP\ILogger;
+use OCP\IRequest;
+use Test\TestCase;
+use Throwable;
+
+class UserStatusControllerTest extends TestCase {
+
+	/** @var ILogger|\PHPUnit\Framework\MockObject\MockObject */
+	private $logger;
+
+	/** @var StatusService|\PHPUnit\Framework\MockObject\MockObject */
+	private $service;
+
+	/** @var UserStatusController */
+	private $controller;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$request = $this->createMock(IRequest::class);
+		$userId = 'john.doe';
+		$this->logger = $this->createMock(ILogger::class);
+		$this->service = $this->createMock(StatusService::class);
+
+		$this->controller = new UserStatusController('user_status', $request, $userId, $this->logger, $this->service);
+	}
+
+	public function testGetStatus(): void {
+		$userStatus = $this->getUserStatus();
+
+		$this->service->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willReturn($userStatus);
+
+		$response = $this->controller->getStatus();
+		$this->assertEquals([
+			'userId' => 'john.doe',
+			'status' => 'invisible',
+			'icon' => '🏝',
+			'message' => 'On vacation',
+			'clearAt' => 60000,
+			'statusIsUserDefined' => true,
+			'messageIsPredefined' => false,
+			'messageId' => null,
+		], $response->getData());
+	}
+
+	public function testGetStatusDoesNotExist(): void {
+		$this->service->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willThrowException(new DoesNotExistException(''));
+
+		$this->expectException(OCSNotFoundException::class);
+		$this->expectExceptionMessage('No status for the current user');
+
+		$this->controller->getStatus();
+	}
+
+	/**
+	 * @param string $statusType
+	 * @param string|null $statusIcon
+	 * @param string|null $message
+	 * @param int|null $clearAt
+	 * @param bool $expectSuccess
+	 * @param bool $expectException
+	 * @param Throwable|null $exception
+	 * @param bool $expectLogger
+	 * @param string|null $expectedLogMessage
+	 *
+	 * @dataProvider setStatusDataProvider
+	 */
+	public function testSetStatus(string $statusType,
+								  ?string $statusIcon,
+								  ?string $message,
+								  ?int $clearAt,
+								  bool $expectSuccess,
+								  bool $expectException,
+								  ?Throwable $exception,
+								  bool $expectLogger,
+								  ?string $expectedLogMessage): void {
+		$userStatus = $this->getUserStatus();
+
+		if ($expectException) {
+			$this->service->expects($this->once())
+				->method('setStatus')
+				->with('john.doe', $statusType, null, true)
+				->willThrowException($exception);
+		} else {
+			$this->service->expects($this->once())
+				->method('setStatus')
+				->with('john.doe', $statusType, null, true)
+				->willReturn($userStatus);
+		}
+
+		if ($expectLogger) {
+			$this->logger->expects($this->once())
+				->method('debug')
+				->with($expectedLogMessage);
+		}
+		if ($expectException) {
+			$this->expectException(OCSBadRequestException::class);
+			$this->expectExceptionMessage('Original exception message');
+		}
+
+		$response = $this->controller->setStatus($statusType);
+
+		if ($expectSuccess) {
+			$this->assertEquals([
+				'userId' => 'john.doe',
+				'status' => 'invisible',
+				'icon' => '🏝',
+				'message' => 'On vacation',
+				'clearAt' => 60000,
+				'statusIsUserDefined' => true,
+				'messageIsPredefined' => false,
+				'messageId' => null,
+			], $response->getData());
+		}
+	}
+
+	public function setStatusDataProvider(): array {
+		return [
+			['busy', '👨🏽‍💻', 'Busy developing the status feature', 500, true, false, null, false, null],
+			['busy', '👨🏽‍💻', 'Busy developing the status feature', 500, false, true, new InvalidStatusTypeException('Original exception message'), true,
+				'New user-status for "john.doe" was rejected due to an invalid status type "busy"'],
+		];
+	}
+
+	/**
+	 * @param string $messageId
+	 * @param int|null $clearAt
+	 * @param bool $expectSuccess
+	 * @param bool $expectException
+	 * @param Throwable|null $exception
+	 * @param bool $expectLogger
+	 * @param string|null $expectedLogMessage
+	 *
+	 * @dataProvider setPredefinedMessageDataProvider
+	 */
+	public function testSetPredefinedMessage(string $messageId,
+											 ?int $clearAt,
+											 bool $expectSuccess,
+											 bool $expectException,
+											 ?Throwable $exception,
+											 bool $expectLogger,
+											 ?string $expectedLogMessage): void {
+		$userStatus = $this->getUserStatus();
+
+		if ($expectException) {
+			$this->service->expects($this->once())
+				->method('setPredefinedMessage')
+				->with('john.doe', $messageId, $clearAt)
+				->willThrowException($exception);
+		} else {
+			$this->service->expects($this->once())
+				->method('setPredefinedMessage')
+				->with('john.doe', $messageId, $clearAt)
+				->willReturn($userStatus);
+		}
+
+		if ($expectLogger) {
+			$this->logger->expects($this->once())
+				->method('debug')
+				->with($expectedLogMessage);
+		}
+		if ($expectException) {
+			$this->expectException(OCSBadRequestException::class);
+			$this->expectExceptionMessage('Original exception message');
+		}
+
+		$response = $this->controller->setPredefinedMessage($messageId, $clearAt);
+
+		if ($expectSuccess) {
+			$this->assertEquals([
+				'userId' => 'john.doe',
+				'status' => 'invisible',
+				'icon' => '🏝',
+				'message' => 'On vacation',
+				'clearAt' => 60000,
+				'statusIsUserDefined' => true,
+				'messageIsPredefined' => false,
+				'messageId' => null,
+			], $response->getData());
+		}
+	}
+
+	public function setPredefinedMessageDataProvider(): array {
+		return [
+			['messageId-42', 500, true, false, null, false, null],
+			['messageId-42', 500, false, true, new InvalidClearAtException('Original exception message'), true,
+				'New user-status for "john.doe" was rejected due to an invalid clearAt value "500"'],
+			['messageId-42', 500, false, true, new InvalidMessageIdException('Original exception message'), true,
+				'New user-status for "john.doe" was rejected due to an invalid message-id "messageId-42"'],
+		];
+	}
+
+	/**
+	 * @param string|null $statusIcon
+	 * @param string $message
+	 * @param int|null $clearAt
+	 * @param bool $expectSuccess
+	 * @param bool $expectException
+	 * @param Throwable|null $exception
+	 * @param bool $expectLogger
+	 * @param string|null $expectedLogMessage
+	 *
+	 * @dataProvider setCustomMessageDataProvider
+	 */
+	public function testSetCustomMessage(?string $statusIcon,
+										 string $message,
+										 ?int $clearAt,
+										 bool $expectSuccess,
+										 bool $expectException,
+										 ?Throwable $exception,
+										 bool $expectLogger,
+										 ?string $expectedLogMessage): void {
+		$userStatus = $this->getUserStatus();
+
+		if ($expectException) {
+			$this->service->expects($this->once())
+				->method('setCustomMessage')
+				->with('john.doe', $statusIcon, $message, $clearAt)
+				->willThrowException($exception);
+		} else {
+			$this->service->expects($this->once())
+				->method('setCustomMessage')
+				->with('john.doe', $statusIcon, $message, $clearAt)
+				->willReturn($userStatus);
+		}
+
+		if ($expectLogger) {
+			$this->logger->expects($this->once())
+				->method('debug')
+				->with($expectedLogMessage);
+		}
+		if ($expectException) {
+			$this->expectException(OCSBadRequestException::class);
+			$this->expectExceptionMessage('Original exception message');
+		}
+
+		$response = $this->controller->setCustomMessage($statusIcon, $message, $clearAt);
+
+		if ($expectSuccess) {
+			$this->assertEquals([
+				'userId' => 'john.doe',
+				'status' => 'invisible',
+				'icon' => '🏝',
+				'message' => 'On vacation',
+				'clearAt' => 60000,
+				'statusIsUserDefined' => true,
+				'messageIsPredefined' => false,
+				'messageId' => null,
+			], $response->getData());
+		}
+	}
+
+	public function setCustomMessageDataProvider(): array {
+		return [
+			['👨🏽‍💻', 'Busy developing the status feature', 500, true, false, null, false, null],
+			['👨🏽‍💻', 'Busy developing the status feature', 500, false, true, new InvalidClearAtException('Original exception message'), true,
+				'New user-status for "john.doe" was rejected due to an invalid clearAt value "500"'],
+			['👨🏽‍💻', 'Busy developing the status feature', 500, false, true, new InvalidStatusIconException('Original exception message'), true,
+				'New user-status for "john.doe" was rejected due to an invalid icon value "👨🏽‍💻"'],
+			['👨🏽‍💻', 'Busy developing the status feature', 500, false, true, new StatusMessageTooLongException('Original exception message'), true,
+				'New user-status for "john.doe" was rejected due to a too long status message.'],
+		];
+	}
+
+	public function testClearStatus(): void {
+		$this->service->expects($this->once())
+			->method('clearStatus')
+			->with('john.doe');
+
+		$response = $this->controller->clearStatus();
+		$this->assertEquals([], $response->getData());
+	}
+
+	public function testClearMessage(): void {
+		$this->service->expects($this->once())
+			->method('clearMessage')
+			->with('john.doe');
+
+		$response = $this->controller->clearMessage();
+		$this->assertEquals([], $response->getData());
+	}
+
+	private function getUserStatus(): UserStatus {
+		$userStatus = new UserStatus();
+		$userStatus->setId(1337);
+		$userStatus->setUserId('john.doe');
+		$userStatus->setStatus('invisible');
+		$userStatus->setStatusTimestamp(5000);
+		$userStatus->setIsUserDefined(true);
+		$userStatus->setCustomIcon('🏝');
+		$userStatus->setCustomMessage('On vacation');
+		$userStatus->setClearAt(60000);
+
+		return $userStatus;
+	}
+}

+ 168 - 0
apps/user_status/tests/Unit/Db/UserStatusMapperTest.php

@@ -0,0 +1,168 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Tests\Db;
+
+use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Db\UserStatusMapper;
+use Test\TestCase;
+
+class UserStatusMapperTest extends TestCase {
+
+	/** @var UserStatusMapper */
+	private $mapper;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		// make sure that DB is empty
+		$qb = self::$realDatabase->getQueryBuilder();
+		$qb->delete('user_status')->execute();
+
+		$this->mapper = new UserStatusMapper(self::$realDatabase);
+	}
+
+	public function testGetTableName(): void {
+		$this->assertEquals('user_status', $this->mapper->getTableName());
+	}
+
+	public function testGetFindAll(): void {
+		$this->insertSampleStatuses();
+
+		$allResults = $this->mapper->findAll();
+		$this->assertCount(3, $allResults);
+
+		$limitedResults = $this->mapper->findAll(2);
+		$this->assertCount(2, $limitedResults);
+		$this->assertEquals('admin', $limitedResults[0]->getUserId());
+		$this->assertEquals('user1', $limitedResults[1]->getUserId());
+
+		$offsetResults = $this->mapper->findAll(null, 2);
+		$this->assertCount(1, $offsetResults);
+		$this->assertEquals('user2', $offsetResults[0]->getUserId());
+	}
+
+	public function testGetFind(): void {
+		$this->insertSampleStatuses();
+
+		$adminStatus = $this->mapper->findByUserId('admin');
+		$this->assertEquals('admin', $adminStatus->getUserId());
+		$this->assertEquals('offline', $adminStatus->getStatus());
+		$this->assertEquals(0, $adminStatus->getStatusTimestamp());
+		$this->assertEquals(false, $adminStatus->getIsUserDefined());
+		$this->assertEquals(null, $adminStatus->getCustomIcon());
+		$this->assertEquals(null, $adminStatus->getCustomMessage());
+		$this->assertEquals(null, $adminStatus->getClearAt());
+
+		$user1Status = $this->mapper->findByUserId('user1');
+		$this->assertEquals('user1', $user1Status->getUserId());
+		$this->assertEquals('dnd', $user1Status->getStatus());
+		$this->assertEquals(5000, $user1Status->getStatusTimestamp());
+		$this->assertEquals(true, $user1Status->getIsUserDefined());
+		$this->assertEquals('💩', $user1Status->getCustomIcon());
+		$this->assertEquals('Do not disturb', $user1Status->getCustomMessage());
+		$this->assertEquals(50000, $user1Status->getClearAt());
+
+		$user2Status = $this->mapper->findByUserId('user2');
+		$this->assertEquals('user2', $user2Status->getUserId());
+		$this->assertEquals('away', $user2Status->getStatus());
+		$this->assertEquals(5000, $user2Status->getStatusTimestamp());
+		$this->assertEquals(false, $user2Status->getIsUserDefined());
+		$this->assertEquals('🏝', $user2Status->getCustomIcon());
+		$this->assertEquals('On vacation', $user2Status->getCustomMessage());
+		$this->assertEquals(60000, $user2Status->getClearAt());
+	}
+
+	public function testUserIdUnique(): void {
+		// Test that inserting a second status for a user is throwing an exception
+
+		$userStatus1 = new UserStatus();
+		$userStatus1->setUserId('admin');
+		$userStatus1->setStatus('dnd');
+		$userStatus1->setStatusTimestamp(5000);
+		$userStatus1->setIsUserDefined(true);
+
+		$this->mapper->insert($userStatus1);
+
+		$userStatus2 = new UserStatus();
+		$userStatus2->setUserId('admin');
+		$userStatus2->setStatus('away');
+		$userStatus2->setStatusTimestamp(6000);
+		$userStatus2->setIsUserDefined(false);
+
+		$this->expectException(UniqueConstraintViolationException::class);
+
+		$this->mapper->insert($userStatus2);
+	}
+
+	public function testClearOlderThan(): void {
+		$this->insertSampleStatuses();
+
+		$this->mapper->clearOlderThan(55000);
+
+		$allStatuses = $this->mapper->findAll();
+		$this->assertCount(3, $allStatuses);
+
+		$user1Status = $this->mapper->findByUserId('user1');
+		$this->assertEquals('user1', $user1Status->getUserId());
+		$this->assertEquals('dnd', $user1Status->getStatus());
+		$this->assertEquals(5000, $user1Status->getStatusTimestamp());
+		$this->assertEquals(true, $user1Status->getIsUserDefined());
+		$this->assertEquals(null, $user1Status->getCustomIcon());
+		$this->assertEquals(null, $user1Status->getCustomMessage());
+		$this->assertEquals(null, $user1Status->getClearAt());
+	}
+
+	private function insertSampleStatuses(): void {
+		$userStatus1 = new UserStatus();
+		$userStatus1->setUserId('admin');
+		$userStatus1->setStatus('offline');
+		$userStatus1->setStatusTimestamp(0);
+		$userStatus1->setIsUserDefined(false);
+
+		$userStatus2 = new UserStatus();
+		$userStatus2->setUserId('user1');
+		$userStatus2->setStatus('dnd');
+		$userStatus2->setStatusTimestamp(5000);
+		$userStatus2->setIsUserDefined(true);
+		$userStatus2->setCustomIcon('💩');
+		$userStatus2->setCustomMessage('Do not disturb');
+		$userStatus2->setClearAt(50000);
+
+		$userStatus3 = new UserStatus();
+		$userStatus3->setUserId('user2');
+		$userStatus3->setStatus('away');
+		$userStatus3->setStatusTimestamp(5000);
+		$userStatus3->setIsUserDefined(false);
+		$userStatus3->setCustomIcon('🏝');
+		$userStatus3->setCustomMessage('On vacation');
+		$userStatus3->setClearAt(60000);
+
+		$this->mapper->insert($userStatus1);
+		$this->mapper->insert($userStatus2);
+		$this->mapper->insert($userStatus3);
+	}
+}

+ 71 - 0
apps/user_status/tests/Unit/Listener/UserDeletedListenerTest.php

@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Tests\Listener;
+
+use OCA\UserStatus\Listener\UserDeletedListener;
+use OCA\UserStatus\Service\StatusService;
+use OCP\EventDispatcher\GenericEvent;
+use OCP\IUser;
+use OCP\User\Events\UserDeletedEvent;
+use Test\TestCase;
+
+class UserDeletedListenerTest extends TestCase {
+
+	/** @var StatusService|\PHPUnit\Framework\MockObject\MockObject */
+	private $service;
+
+	/** @var UserDeletedListener */
+	private $listener;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->service = $this->createMock(StatusService::class);
+		$this->listener = new UserDeletedListener($this->service);
+	}
+
+	public function testHandleWithCorrectEvent(): void {
+		$user = $this->createMock(IUser::class);
+		$user->expects($this->once())
+			->method('getUID')
+			->willReturn('john.doe');
+
+		$this->service->expects($this->once())
+			->method('removeUserStatus')
+			->with('john.doe');
+
+		$event = new UserDeletedEvent($user);
+		$this->listener->handle($event);
+	}
+
+	public function testHandleWithWrongEvent(): void {
+		$this->service->expects($this->never())
+			->method('removeUserStatus');
+
+		$event = new GenericEvent();
+		$this->listener->handle($event);
+	}
+}

+ 162 - 0
apps/user_status/tests/Unit/Listener/UserLiveStatusListenerTest.php

@@ -0,0 +1,162 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Tests\Listener;
+
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Db\UserStatusMapper;
+use OCA\UserStatus\Listener\UserDeletedListener;
+use OCA\UserStatus\Listener\UserLiveStatusListener;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\EventDispatcher\GenericEvent;
+use OCP\IUser;
+use OCP\User\Events\UserLiveStatusEvent;
+use Test\TestCase;
+
+class UserLiveStatusListenerTest extends TestCase {
+
+	/** @var UserStatusMapper|\PHPUnit\Framework\MockObject\MockObject */
+	private $mapper;
+
+	/** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */
+	private $timeFactory;
+
+	/** @var UserDeletedListener */
+	private $listener;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->mapper = $this->createMock(UserStatusMapper::class);
+		$this->timeFactory = $this->createMock(ITimeFactory::class);
+		$this->listener = new UserLiveStatusListener($this->mapper, $this->timeFactory);
+	}
+
+	/**
+	 * @param string $userId
+	 * @param string $previousStatus
+	 * @param int $previousTimestamp
+	 * @param bool $previousIsUserDefined
+	 * @param string $eventStatus
+	 * @param int $eventTimestamp
+	 * @param bool $expectExisting
+	 * @param bool $expectUpdate
+	 *
+	 * @dataProvider handleEventWithCorrectEventDataProvider
+	 */
+	public function testHandleWithCorrectEvent(string $userId,
+											   string $previousStatus,
+											   int $previousTimestamp,
+											   bool $previousIsUserDefined,
+											   string $eventStatus,
+											   int $eventTimestamp,
+											   bool $expectExisting,
+											   bool $expectUpdate): void {
+		$userStatus = new UserStatus();
+
+		if ($expectExisting) {
+			$userStatus->setId(42);
+			$userStatus->setUserId($userId);
+			$userStatus->setStatus($previousStatus);
+			$userStatus->setStatusTimestamp($previousTimestamp);
+			$userStatus->setIsUserDefined($previousIsUserDefined);
+
+			$this->mapper->expects($this->once())
+				->method('findByUserId')
+				->with($userId)
+				->willReturn($userStatus);
+		} else {
+			$this->mapper->expects($this->once())
+				->method('findByUserId')
+				->with($userId)
+				->willThrowException(new DoesNotExistException(''));
+		}
+
+		$user = $this->createMock(IUser::class);
+		$user->method('getUID')->willReturn($userId);
+		$event = new UserLiveStatusEvent($user, $eventStatus, $eventTimestamp);
+
+		$this->timeFactory->expects($this->once())
+			->method('getTime')
+			->willReturn(5000);
+
+		if ($expectUpdate) {
+			if ($expectExisting) {
+				$this->mapper->expects($this->never())
+					->method('insert');
+				$this->mapper->expects($this->once())
+					->method('update')
+					->with($this->callback(function ($userStatus) use ($eventStatus, $eventTimestamp) {
+						$this->assertEquals($eventStatus, $userStatus->getStatus());
+						$this->assertEquals($eventTimestamp, $userStatus->getStatusTimestamp());
+						$this->assertFalse($userStatus->getIsUserDefined());
+
+						return true;
+					}));
+			} else {
+				$this->mapper->expects($this->once())
+					->method('insert')
+					->with($this->callback(function ($userStatus) use ($eventStatus, $eventTimestamp) {
+						$this->assertEquals($eventStatus, $userStatus->getStatus());
+						$this->assertEquals($eventTimestamp, $userStatus->getStatusTimestamp());
+						$this->assertFalse($userStatus->getIsUserDefined());
+
+						return true;
+					}));
+				$this->mapper->expects($this->never())
+					->method('update');
+			}
+
+			$this->listener->handle($event);
+		} else {
+			$this->mapper->expects($this->never())
+				->method('insert');
+			$this->mapper->expects($this->never())
+				->method('update');
+
+			$this->listener->handle($event);
+		}
+	}
+
+	public function handleEventWithCorrectEventDataProvider(): array {
+		return [
+			['john.doe', 'offline', 0, false, 'online', 5000, true, true],
+			['john.doe', 'offline', 0, false, 'online', 5000, false, true],
+			['john.doe', 'online', 5000, false, 'online', 5000, true, false],
+			['john.doe', 'online', 5000, false, 'online', 5000, false, true],
+			['john.doe', 'away', 5000, false, 'online', 5000, true, true],
+			['john.doe', 'online', 5000, false, 'away', 5000, true, false],
+		];
+	}
+
+	public function testHandleWithWrongEvent(): void {
+		$this->mapper->expects($this->never())
+			->method('insertOrUpdate');
+
+		$event = new GenericEvent();
+		$this->listener->handle($event);
+	}
+}

+ 100 - 0
apps/user_status/tests/Unit/Service/EmojiServiceTest.php

@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Tests\Service;
+
+use OCA\UserStatus\Service\EmojiService;
+use OCP\IDBConnection;
+use Test\TestCase;
+
+class EmojiServiceTest extends TestCase {
+
+	/** @var IDBConnection|\PHPUnit\Framework\MockObject\MockObject */
+	private $db;
+
+	/** @var EmojiService */
+	private $service;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->db = $this->createMock(IDBConnection::class);
+		$this->service = new EmojiService($this->db);
+	}
+
+	/**
+	 * @param bool $supports4ByteText
+	 * @param bool $expected
+	 *
+	 * @dataProvider doesPlatformSupportEmojiDataProvider
+	 */
+	public function testDoesPlatformSupportEmoji(bool $supports4ByteText, bool $expected): void {
+		$this->db->expects($this->once())
+			->method('supports4ByteText')
+			->willReturn($supports4ByteText);
+
+		$this->assertEquals($expected, $this->service->doesPlatformSupportEmoji());
+	}
+
+	/**
+	 * @return array
+	 */
+	public function doesPlatformSupportEmojiDataProvider(): array {
+		return [
+			[true, true],
+			[false, false],
+		];
+	}
+
+	/**
+	 * @param string $emoji
+	 * @param bool $expected
+	 *
+	 * @dataProvider isValidEmojiDataProvider
+	 */
+	public function testIsValidEmoji(string $emoji, bool $expected): void {
+		$actual = $this->service->isValidEmoji($emoji);
+
+		$this->assertEquals($expected, $actual);
+	}
+
+	public function isValidEmojiDataProvider(): array {
+		return [
+			['🏝', true],
+			['📱', true],
+			['🏢', true],
+			['📱📠', false],
+			['a', false],
+			['0', false],
+			['$', false],
+			// Test some more complex emojis with modifiers and zero-width-joiner
+			['👩🏿‍💻', true],
+			['🤷🏼‍♀️', true],
+			['🏳️‍🌈', true],
+			['👨‍👨‍👦‍👦', true],
+			['👩‍❤️‍👩', true]
+		];
+	}
+}

+ 184 - 0
apps/user_status/tests/Unit/Service/PredefinedStatusServiceTest.php

@@ -0,0 +1,184 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Tests\Service;
+
+use OCA\UserStatus\Service\PredefinedStatusService;
+use OCP\IL10N;
+use Test\TestCase;
+
+class PredefinedStatusServiceTest extends TestCase {
+
+	/** @var IL10N|\PHPUnit\Framework\MockObject\MockObject */
+	protected $l10n;
+
+	/** @var PredefinedStatusService */
+	protected $service;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->l10n = $this->createMock(IL10N::class);
+
+		$this->service = new PredefinedStatusService($this->l10n);
+	}
+
+	public function testGetDefaultStatuses(): void {
+		$this->l10n->expects($this->exactly(5))
+			->method('t')
+			->withConsecutive(
+				['In a meeting'],
+				['Commuting'],
+				['Working remotely'],
+				['Out sick'],
+				['Vacationing']
+			)
+			->willReturnArgument(0);
+
+		$actual = $this->service->getDefaultStatuses();
+		$this->assertEquals([
+			[
+				'id' => 'meeting',
+				'icon' => '📅',
+				'message' => 'In a meeting',
+				'clearAt' => [
+					'type' => 'period',
+					'time' => 3600,
+				],
+			],
+			[
+				'id' => 'commuting',
+				'icon' => '🚌',
+				'message' => 'Commuting',
+				'clearAt' => [
+					'type' => 'period',
+					'time' => 1800,
+				],
+			],
+			[
+				'id' => 'remote-work',
+				'icon' => '🏡',
+				'message' => 'Working remotely',
+				'clearAt' => [
+					'type' => 'end-of',
+					'time' => 'day',
+				],
+			],
+			[
+				'id' => 'sick-leave',
+				'icon' => '🤒',
+				'message' => 'Out sick',
+				'clearAt' => [
+					'type' => 'end-of',
+					'time' => 'day',
+				],
+			],
+			[
+				'id' => 'vacationing',
+				'icon' => '🌴',
+				'message' => 'Vacationing',
+				'clearAt' => null,
+			],
+		], $actual);
+	}
+
+	/**
+	 * @param string $id
+	 * @param string|null $expectedIcon
+	 *
+	 * @dataProvider getIconForIdDataProvider
+	 */
+	public function testGetIconForId(string $id, ?string $expectedIcon): void {
+		$actual = $this->service->getIconForId($id);
+		$this->assertEquals($expectedIcon, $actual);
+	}
+
+	/**
+	 * @return array
+	 */
+	public function getIconForIdDataProvider(): array {
+		return [
+			['meeting', '📅'],
+			['commuting', '🚌'],
+			['sick-leave', '🤒'],
+			['vacationing', '🌴'],
+			['remote-work', '🏡'],
+			['unknown-id', null],
+		];
+	}
+
+	/**
+	 * @param string $id
+	 * @param string|null $expected
+	 *
+	 * @dataProvider getTranslatedStatusForIdDataProvider
+	 */
+	public function testGetTranslatedStatusForId(string $id, ?string $expected): void {
+		$this->l10n->method('t')
+			->willReturnArgument(0);
+
+		$actual = $this->service->getTranslatedStatusForId($id);
+		$this->assertEquals($expected, $actual);
+	}
+
+	/**
+	 * @return array
+	 */
+	public function getTranslatedStatusForIdDataProvider(): array {
+		return [
+			['meeting', 'In a meeting'],
+			['commuting', 'Commuting'],
+			['sick-leave', 'Out sick'],
+			['vacationing', 'Vacationing'],
+			['remote-work', 'Working remotely'],
+			['unknown-id', null],
+		];
+	}
+
+	/**
+	 * @param string $id
+	 * @param bool $expected
+	 *
+	 * @dataProvider isValidIdDataProvider
+	 */
+	public function testIsValidId(string $id, bool $expected): void {
+		$actual = $this->service->isValidId($id);
+		$this->assertEquals($expected, $actual);
+	}
+
+	/**
+	 * @return array
+	 */
+	public function isValidIdDataProvider(): array {
+		return [
+			['meeting', true],
+			['commuting', true],
+			['sick-leave', true],
+			['vacationing', true],
+			['remote-work', true],
+			['unknown-id', false],
+		];
+	}
+}

+ 592 - 0
apps/user_status/tests/Unit/Service/StatusServiceTest.php

@@ -0,0 +1,592 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\UserStatus\Tests\Service;
+
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Db\UserStatusMapper;
+use OCA\UserStatus\Exception\InvalidClearAtException;
+use OCA\UserStatus\Exception\InvalidMessageIdException;
+use OCA\UserStatus\Exception\InvalidStatusIconException;
+use OCA\UserStatus\Exception\InvalidStatusTypeException;
+use OCA\UserStatus\Exception\StatusMessageTooLongException;
+use OCA\UserStatus\Service\EmojiService;
+use OCA\UserStatus\Service\PredefinedStatusService;
+use OCA\UserStatus\Service\StatusService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Utility\ITimeFactory;
+use Test\TestCase;
+
+class StatusServiceTest extends TestCase {
+
+	/** @var UserStatusMapper|\PHPUnit\Framework\MockObject\MockObject */
+	private $mapper;
+
+	/** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */
+	private $timeFactory;
+
+	/** @var PredefinedStatusService|\PHPUnit\Framework\MockObject\MockObject */
+	private $predefinedStatusService;
+
+	/** @var EmojiService|\PHPUnit\Framework\MockObject\MockObject */
+	private $emojiService;
+
+	/** @var StatusService */
+	private $service;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->mapper = $this->createMock(UserStatusMapper::class);
+		$this->timeFactory = $this->createMock(ITimeFactory::class);
+		$this->predefinedStatusService = $this->createMock(PredefinedStatusService::class);
+		$this->emojiService = $this->createMock(EmojiService::class);
+		$this->service = new StatusService($this->mapper,
+			$this->timeFactory,
+			$this->predefinedStatusService,
+			$this->emojiService);
+	}
+
+	public function testFindAll(): void {
+		$status1 = $this->createMock(UserStatus::class);
+		$status2 = $this->createMock(UserStatus::class);
+
+		$this->mapper->expects($this->once())
+			->method('findAll')
+			->with(20, 50)
+			->willReturn([$status1, $status2]);
+
+		$this->assertEquals([
+			$status1,
+			$status2,
+		], $this->service->findAll(20, 50));
+	}
+
+	public function testFindByUserId(): void {
+		$status = $this->createMock(UserStatus::class);
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willReturn($status);
+
+		$this->assertEquals($status, $this->service->findByUserId('john.doe'));
+	}
+
+	public function testFindByUserIdDoesNotExist(): void {
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willThrowException(new DoesNotExistException(''));
+
+		$this->expectException(DoesNotExistException::class);
+		$this->service->findByUserId('john.doe');
+	}
+
+	public function testFindAllAddDefaultMessage(): void {
+		$status = new UserStatus();
+		$status->setMessageId('commuting');
+
+		$this->predefinedStatusService->expects($this->once())
+			->method('getDefaultStatusById')
+			->with('commuting')
+			->willReturn([
+				'id' => 'commuting',
+				'icon' => '🚌',
+				'message' => 'Commuting',
+				'clearAt' => [
+					'type' => 'period',
+					'time' => 1800,
+				],
+			]);
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willReturn($status);
+
+		$this->assertEquals($status, $this->service->findByUserId('john.doe'));
+		$this->assertEquals('🚌', $status->getCustomIcon());
+		$this->assertEquals('Commuting', $status->getCustomMessage());
+	}
+
+	public function testFindAllClearStatus(): void {
+		$status = new UserStatus();
+		$status->setClearAt(50);
+		$status->setMessageId('commuting');
+
+		$this->timeFactory->expects($this->once())
+			->method('getTime')
+			->willReturn(60);
+		$this->predefinedStatusService->expects($this->never())
+			->method('getDefaultStatusById');
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willReturn($status);
+		$this->assertEquals($status, $this->service->findByUserId('john.doe'));
+		$this->assertNull($status->getClearAt());
+		$this->assertNull($status->getMessageId());
+	}
+
+	/**
+	 * @param string $userId
+	 * @param string $status
+	 * @param int|null $statusTimestamp
+	 * @param bool $isUserDefined
+	 * @param bool $expectExisting
+	 * @param bool $expectSuccess
+	 * @param bool $expectTimeFactory
+	 * @param bool $expectException
+	 * @param string|null $expectedExceptionClass
+	 * @param string|null $expectedExceptionMessage
+	 *
+	 * @dataProvider setStatusDataProvider
+	 */
+	public function testSetStatus(string $userId,
+								  string $status,
+								  ?int $statusTimestamp,
+								  bool $isUserDefined,
+								  bool $expectExisting,
+								  bool $expectSuccess,
+								  bool $expectTimeFactory,
+								  bool $expectException,
+								  ?string $expectedExceptionClass,
+								  ?string $expectedExceptionMessage): void {
+		$userStatus = new UserStatus();
+
+		if ($expectExisting) {
+			$userStatus->setId(42);
+			$userStatus->setUserId($userId);
+
+			$this->mapper->expects($this->once())
+				->method('findByUserId')
+				->with($userId)
+				->willReturn($userStatus);
+		} else {
+			$this->mapper->expects($this->once())
+				->method('findByUserId')
+				->with($userId)
+				->willThrowException(new DoesNotExistException(''));
+		}
+
+		if ($expectTimeFactory) {
+			$this->timeFactory
+				->method('getTime')
+				->willReturn(40);
+		}
+
+		if ($expectException) {
+			$this->expectException($expectedExceptionClass);
+			$this->expectExceptionMessage($expectedExceptionMessage);
+
+			$this->service->setStatus($userId, $status, $statusTimestamp, $isUserDefined);
+		}
+
+		if ($expectSuccess) {
+			if ($expectExisting) {
+				$this->mapper->expects($this->once())
+					->method('update')
+					->willReturnArgument(0);
+			} else {
+				$this->mapper->expects($this->once())
+					->method('insert')
+					->willReturnArgument(0);
+			}
+
+			$actual = $this->service->setStatus($userId, $status, $statusTimestamp, $isUserDefined);
+
+			$this->assertEquals('john.doe', $actual->getUserId());
+			$this->assertEquals($status, $actual->getStatus());
+			$this->assertEquals($statusTimestamp ?? 40, $actual->getStatusTimestamp());
+			$this->assertEquals($isUserDefined, $actual->getIsUserDefined());
+		}
+	}
+
+	public function setStatusDataProvider(): array {
+		return [
+			['john.doe', 'online', 50,   true,  true,  true, false, false, null, null],
+			['john.doe', 'online', 50,   true,  false, true, false, false, null, null],
+			['john.doe', 'online', 50,   false, true,  true, false, false, null, null],
+			['john.doe', 'online', 50,   false, false, true, false, false, null, null],
+			['john.doe', 'online', null, true,  true,  true, true,  false, null, null],
+			['john.doe', 'online', null, true,  false, true, true,  false, null, null],
+			['john.doe', 'online', null, false, true,  true, true,  false, null, null],
+			['john.doe', 'online', null, false, false, true, true,  false, null, null],
+
+			['john.doe', 'away', 50,   true,  true,  true, false, false, null, null],
+			['john.doe', 'away', 50,   true,  false, true, false, false, null, null],
+			['john.doe', 'away', 50,   false, true,  true, false, false, null, null],
+			['john.doe', 'away', 50,   false, false, true, false, false, null, null],
+			['john.doe', 'away', null, true,  true,  true, true,  false, null, null],
+			['john.doe', 'away', null, true,  false, true, true,  false, null, null],
+			['john.doe', 'away', null, false, true,  true, true,  false, null, null],
+			['john.doe', 'away', null, false, false, true, true,  false, null, null],
+
+			['john.doe', 'dnd', 50,   true,  true,  true, false, false, null, null],
+			['john.doe', 'dnd', 50,   true,  false, true, false, false, null, null],
+			['john.doe', 'dnd', 50,   false, true,  true, false, false, null, null],
+			['john.doe', 'dnd', 50,   false, false, true, false, false, null, null],
+			['john.doe', 'dnd', null, true,  true,  true, true,  false, null, null],
+			['john.doe', 'dnd', null, true,  false, true, true,  false, null, null],
+			['john.doe', 'dnd', null, false, true,  true, true,  false, null, null],
+			['john.doe', 'dnd', null, false, false, true, true,  false, null, null],
+
+			['john.doe', 'invisible', 50,   true,  true,  true, false, false, null, null],
+			['john.doe', 'invisible', 50,   true,  false, true, false, false, null, null],
+			['john.doe', 'invisible', 50,   false, true,  true, false, false, null, null],
+			['john.doe', 'invisible', 50,   false, false, true, false, false, null, null],
+			['john.doe', 'invisible', null, true,  true,  true, true,  false, null, null],
+			['john.doe', 'invisible', null, true,  false, true, true,  false, null, null],
+			['john.doe', 'invisible', null, false, true,  true, true,  false, null, null],
+			['john.doe', 'invisible', null, false, false, true, true,  false, null, null],
+
+			['john.doe', 'offline', 50,   true,  true,  true, false, false, null, null],
+			['john.doe', 'offline', 50,   true,  false, true, false, false, null, null],
+			['john.doe', 'offline', 50,   false, true,  true, false, false, null, null],
+			['john.doe', 'offline', 50,   false, false, true, false, false, null, null],
+			['john.doe', 'offline', null, true,  true,  true, true,  false, null, null],
+			['john.doe', 'offline', null, true,  false, true, true,  false, null, null],
+			['john.doe', 'offline', null, false, true,  true, true,  false, null, null],
+			['john.doe', 'offline', null, false, false, true, true,  false, null, null],
+
+			['john.doe', 'illegal-status', 50,   true,  true,  false, false, true, InvalidStatusTypeException::class, 'Status-type "illegal-status" is not supported'],
+			['john.doe', 'illegal-status', 50,   true,  false, false, false, true, InvalidStatusTypeException::class, 'Status-type "illegal-status" is not supported'],
+			['john.doe', 'illegal-status', 50,   false, true,  false, false, true, InvalidStatusTypeException::class, 'Status-type "illegal-status" is not supported'],
+			['john.doe', 'illegal-status', 50,   false, false, false, false, true, InvalidStatusTypeException::class, 'Status-type "illegal-status" is not supported'],
+			['john.doe', 'illegal-status', null, true,  true,  false, true,  true, InvalidStatusTypeException::class, 'Status-type "illegal-status" is not supported'],
+			['john.doe', 'illegal-status', null, true,  false, false, true,  true, InvalidStatusTypeException::class, 'Status-type "illegal-status" is not supported'],
+			['john.doe', 'illegal-status', null, false, true,  false, true,  true, InvalidStatusTypeException::class, 'Status-type "illegal-status" is not supported'],
+			['john.doe', 'illegal-status', null, false, false, false, true,  true, InvalidStatusTypeException::class, 'Status-type "illegal-status" is not supported'],
+		];
+	}
+
+	/**
+	 * @param string $userId
+	 * @param string $messageId
+	 * @param bool $isValidMessageId
+	 * @param int|null $clearAt
+	 * @param bool $expectExisting
+	 * @param bool $expectSuccess
+	 * @param bool $expectException
+	 * @param string|null $expectedExceptionClass
+	 * @param string|null $expectedExceptionMessage
+	 *
+	 * @dataProvider setPredefinedMessageDataProvider
+	 */
+	public function testSetPredefinedMessage(string $userId,
+											 string $messageId,
+											 bool $isValidMessageId,
+											 ?int $clearAt,
+											 bool $expectExisting,
+											 bool $expectSuccess,
+											 bool $expectException,
+											 ?string $expectedExceptionClass,
+											 ?string $expectedExceptionMessage): void {
+		$userStatus = new UserStatus();
+
+		if ($expectExisting) {
+			$userStatus->setId(42);
+			$userStatus->setUserId($userId);
+			$userStatus->setStatus('offline');
+			$userStatus->setStatusTimestamp(0);
+			$userStatus->setIsUserDefined(false);
+			$userStatus->setCustomIcon('😀');
+			$userStatus->setCustomMessage('Foo');
+
+			$this->mapper->expects($this->once())
+				->method('findByUserId')
+				->with($userId)
+				->willReturn($userStatus);
+		} else {
+			$this->mapper->expects($this->once())
+				->method('findByUserId')
+				->with($userId)
+				->willThrowException(new DoesNotExistException(''));
+		}
+
+		$this->predefinedStatusService->expects($this->once())
+			->method('isValidId')
+			->with($messageId)
+			->willReturn($isValidMessageId);
+
+		$this->timeFactory
+			->method('getTime')
+			->willReturn(40);
+
+		if ($expectException) {
+			$this->expectException($expectedExceptionClass);
+			$this->expectExceptionMessage($expectedExceptionMessage);
+
+			$this->service->setPredefinedMessage($userId, $messageId, $clearAt);
+		}
+
+		if ($expectSuccess) {
+			if ($expectExisting) {
+				$this->mapper->expects($this->once())
+					->method('update')
+					->willReturnArgument(0);
+			} else {
+				$this->mapper->expects($this->once())
+					->method('insert')
+					->willReturnArgument(0);
+			}
+
+			$actual = $this->service->setPredefinedMessage($userId, $messageId, $clearAt);
+
+			$this->assertEquals('john.doe', $actual->getUserId());
+			$this->assertEquals('offline', $actual->getStatus());
+			$this->assertEquals(0, $actual->getStatusTimestamp());
+			$this->assertEquals(false, $actual->getIsUserDefined());
+			$this->assertEquals($messageId, $actual->getMessageId());
+			$this->assertNull($actual->getCustomIcon());
+			$this->assertNull($actual->getCustomMessage());
+			$this->assertEquals($clearAt, $actual->getClearAt());
+		}
+	}
+
+	public function setPredefinedMessageDataProvider(): array {
+		return [
+			['john.doe', 'sick-leave', true, null, true,  true,  false, null, null],
+			['john.doe', 'sick-leave', true, null, false, true,  false, null, null],
+			['john.doe', 'sick-leave', true, 20,   true,  false, true,  InvalidClearAtException::class, 'ClearAt is in the past'],
+			['john.doe', 'sick-leave', true, 20,   false, false, true,  InvalidClearAtException::class, 'ClearAt is in the past'],
+			['john.doe', 'sick-leave', true, 60,   true,  true,  false, null, null],
+			['john.doe', 'sick-leave', true, 60,   false, true,  false, null, null],
+			['john.doe', 'illegal-message-id', false, null, true, false, true, InvalidMessageIdException::class, 'Message-Id "illegal-message-id" is not supported'],
+			['john.doe', 'illegal-message-id', false, null, false, false, true, InvalidMessageIdException::class, 'Message-Id "illegal-message-id" is not supported'],
+		];
+	}
+
+	/**
+	 * @param string $userId
+	 * @param string|null $statusIcon
+	 * @param bool $supportsEmoji
+	 * @param string $message
+	 * @param int|null $clearAt
+	 * @param bool $expectExisting
+	 * @param bool $expectSuccess
+	 * @param bool $expectException
+	 * @param string|null $expectedExceptionClass
+	 * @param string|null $expectedExceptionMessage
+	 *
+	 * @dataProvider setCustomMessageDataProvider
+	 */
+	public function testSetCustomMessage(string $userId,
+										 ?string $statusIcon,
+										 bool $supportsEmoji,
+										 string $message,
+										 ?int $clearAt,
+										 bool $expectExisting,
+										 bool $expectSuccess,
+										 bool $expectException,
+										 ?string $expectedExceptionClass,
+										 ?string $expectedExceptionMessage): void {
+		$userStatus = new UserStatus();
+
+		if ($expectExisting) {
+			$userStatus->setId(42);
+			$userStatus->setUserId($userId);
+			$userStatus->setStatus('offline');
+			$userStatus->setStatusTimestamp(0);
+			$userStatus->setIsUserDefined(false);
+			$userStatus->setMessageId('messageId-42');
+
+			$this->mapper->expects($this->once())
+				->method('findByUserId')
+				->with($userId)
+				->willReturn($userStatus);
+		} else {
+			$this->mapper->expects($this->once())
+				->method('findByUserId')
+				->with($userId)
+				->willThrowException(new DoesNotExistException(''));
+		}
+
+		$this->emojiService->method('isValidEmoji')
+			->with($statusIcon)
+			->willReturn($supportsEmoji);
+
+		$this->timeFactory
+			->method('getTime')
+			->willReturn(40);
+
+		if ($expectException) {
+			$this->expectException($expectedExceptionClass);
+			$this->expectExceptionMessage($expectedExceptionMessage);
+
+			$this->service->setCustomMessage($userId, $statusIcon, $message, $clearAt);
+		}
+
+		if ($expectSuccess) {
+			if ($expectExisting) {
+				$this->mapper->expects($this->once())
+					->method('update')
+					->willReturnArgument(0);
+			} else {
+				$this->mapper->expects($this->once())
+					->method('insert')
+					->willReturnArgument(0);
+			}
+
+			$actual = $this->service->setCustomMessage($userId, $statusIcon, $message, $clearAt);
+
+			$this->assertEquals('john.doe', $actual->getUserId());
+			$this->assertEquals('offline', $actual->getStatus());
+			$this->assertEquals(0, $actual->getStatusTimestamp());
+			$this->assertEquals(false, $actual->getIsUserDefined());
+			$this->assertNull($actual->getMessageId());
+			$this->assertEquals($statusIcon, $actual->getCustomIcon());
+			$this->assertEquals($message, $actual->getCustomMessage());
+			$this->assertEquals($clearAt, $actual->getClearAt());
+		}
+	}
+
+	public function setCustomMessageDataProvider(): array {
+		return [
+			['john.doe', '😁', true, 'Custom message', null, true,  true, false, null, null],
+			['john.doe', '😁', true, 'Custom message', null, false, true, false, null, null],
+			['john.doe', null, false, 'Custom message', null, true,  true, false, null, null],
+			['john.doe', null, false, 'Custom message', null, false, true, false, null, null],
+			['john.doe', '😁', false, 'Custom message', null, true,  false, true, InvalidStatusIconException::class, 'Status-Icon is longer than one character'],
+			['john.doe', '😁', false, 'Custom message', null, false, false, true, InvalidStatusIconException::class, 'Status-Icon is longer than one character'],
+			['john.doe', null, false, 'Custom message that is way too long and violates the maximum length and hence should be rejected', null, true,  false, true, StatusMessageTooLongException::class, 'Message is longer than supported length of 80 characters'],
+			['john.doe', null, false, 'Custom message that is way too long and violates the maximum length and hence should be rejected', null, false, false, true, StatusMessageTooLongException::class, 'Message is longer than supported length of 80 characters'],
+			['john.doe', '😁', true, 'Custom message', 80, true,  true, false, null, null],
+			['john.doe', '😁', true, 'Custom message', 80, false, true, false, null, null],
+			['john.doe', '😁', true, 'Custom message', 20, true,  false, true, InvalidClearAtException::class, 'ClearAt is in the past'],
+			['john.doe', '😁', true, 'Custom message', 20, false, false, true, InvalidClearAtException::class, 'ClearAt is in the past'],
+		];
+	}
+
+	public function testClearStatus(): void {
+		$status = new UserStatus();
+		$status->setId(1);
+		$status->setUserId('john.doe');
+		$status->setStatus('dnd');
+		$status->setStatusTimestamp(1337);
+		$status->setIsUserDefined(true);
+		$status->setMessageId('messageId-42');
+		$status->setCustomIcon('🙊');
+		$status->setCustomMessage('My custom status message');
+		$status->setClearAt(42);
+
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willReturn($status);
+
+		$this->mapper->expects($this->once())
+			->method('update')
+			->with($status);
+
+		$actual = $this->service->clearStatus('john.doe');
+		$this->assertTrue($actual);
+		$this->assertEquals('offline', $status->getStatus());
+		$this->assertEquals(0, $status->getStatusTimestamp());
+		$this->assertFalse($status->getIsUserDefined());
+	}
+
+	public function testClearStatusDoesNotExist(): void {
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willThrowException(new DoesNotExistException(''));
+
+		$this->mapper->expects($this->never())
+			->method('update');
+
+		$actual = $this->service->clearStatus('john.doe');
+		$this->assertFalse($actual);
+	}
+
+	public function testClearMessage(): void {
+		$status = new UserStatus();
+		$status->setId(1);
+		$status->setUserId('john.doe');
+		$status->setStatus('dnd');
+		$status->setStatusTimestamp(1337);
+		$status->setIsUserDefined(true);
+		$status->setMessageId('messageId-42');
+		$status->setCustomIcon('🙊');
+		$status->setCustomMessage('My custom status message');
+		$status->setClearAt(42);
+
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willReturn($status);
+
+		$this->mapper->expects($this->once())
+			->method('update')
+			->with($status);
+
+		$actual = $this->service->clearMessage('john.doe');
+		$this->assertTrue($actual);
+		$this->assertNull($status->getMessageId());
+		$this->assertNull($status->getCustomMessage());
+		$this->assertNull($status->getCustomIcon());
+		$this->assertNull($status->getClearAt());
+	}
+
+	public function testClearMessageDoesNotExist(): void {
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willThrowException(new DoesNotExistException(''));
+
+		$this->mapper->expects($this->never())
+			->method('update');
+
+		$actual = $this->service->clearMessage('john.doe');
+		$this->assertFalse($actual);
+	}
+
+	public function testRemoveUserStatus(): void {
+		$status = $this->createMock(UserStatus::class);
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willReturn($status);
+
+		$this->mapper->expects($this->once())
+			->method('delete')
+			->with($status);
+
+		$actual = $this->service->removeUserStatus('john.doe');
+		$this->assertTrue($actual);
+	}
+
+	public function testRemoveUserStatusDoesNotExist(): void {
+		$this->mapper->expects($this->once())
+			->method('findByUserId')
+			->with('john.doe')
+			->willThrowException(new DoesNotExistException(''));
+
+		$this->mapper->expects($this->never())
+			->method('delete');
+
+		$actual = $this->service->removeUserStatus('john.doe');
+		$this->assertFalse($actual);
+	}
+}

+ 36 - 0
apps/user_status/tests/bootstrap.php

@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+if (!defined('PHPUNIT_RUN')) {
+	define('PHPUNIT_RUN', 1);
+}
+
+require_once __DIR__.'/../../../lib/base.php';
+
+\OC::$composerAutoloader->addPsr4('Test\\', OC::$SERVERROOT . '/tests/lib/', true);
+
+\OC_App::loadApp('user_status');
+
+OC_Hook::clear();

+ 18 - 0
apps/user_status/webpack.js

@@ -0,0 +1,18 @@
+const path = require('path')
+
+module.exports = {
+	entry: {
+		'user-status-menu': path.join(__dirname, 'src', 'main-user-status-menu')
+	},
+	output: {
+		path: path.resolve(__dirname, './js'),
+		publicPath: '/js/',
+		filename: '[name].js?v=[chunkhash]',
+		jsonpFunction: 'webpackJsonpUserStatus'
+	},
+	optimization: {
+		splitChunks: {
+			automaticNameDelimiter: '-',
+		}
+	}
+}

+ 1 - 0
lib/composer/composer/autoload_classmap.php

@@ -519,6 +519,7 @@ return array(
     'OCP\\User\\Events\\UserChangedEvent' => $baseDir . '/lib/public/User/Events/UserChangedEvent.php',
     'OCP\\User\\Events\\UserCreatedEvent' => $baseDir . '/lib/public/User/Events/UserCreatedEvent.php',
     'OCP\\User\\Events\\UserDeletedEvent' => $baseDir . '/lib/public/User/Events/UserDeletedEvent.php',
+    'OCP\\User\\Events\\UserLiveStatusEvent' => $baseDir . '/lib/public/User/Events/UserLiveStatusEvent.php',
     'OCP\\User\\Events\\UserLoggedInEvent' => $baseDir . '/lib/public/User/Events/UserLoggedInEvent.php',
     'OCP\\User\\Events\\UserLoggedInWithCookieEvent' => $baseDir . '/lib/public/User/Events/UserLoggedInWithCookieEvent.php',
     'OCP\\User\\Events\\UserLoggedOutEvent' => $baseDir . '/lib/public/User/Events/UserLoggedOutEvent.php',

+ 1 - 0
lib/composer/composer/autoload_static.php

@@ -548,6 +548,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
         'OCP\\User\\Events\\UserChangedEvent' => __DIR__ . '/../../..' . '/lib/public/User/Events/UserChangedEvent.php',
         'OCP\\User\\Events\\UserCreatedEvent' => __DIR__ . '/../../..' . '/lib/public/User/Events/UserCreatedEvent.php',
         'OCP\\User\\Events\\UserDeletedEvent' => __DIR__ . '/../../..' . '/lib/public/User/Events/UserDeletedEvent.php',
+        'OCP\\User\\Events\\UserLiveStatusEvent' => __DIR__ . '/../../..' . '/lib/public/User/Events/UserLiveStatusEvent.php',
         'OCP\\User\\Events\\UserLoggedInEvent' => __DIR__ . '/../../..' . '/lib/public/User/Events/UserLoggedInEvent.php',
         'OCP\\User\\Events\\UserLoggedInWithCookieEvent' => __DIR__ . '/../../..' . '/lib/public/User/Events/UserLoggedInWithCookieEvent.php',
         'OCP\\User\\Events\\UserLoggedOutEvent' => __DIR__ . '/../../..' . '/lib/public/User/Events/UserLoggedOutEvent.php',

+ 4 - 4
lib/private/NavigationManager.php

@@ -200,7 +200,7 @@ class NavigationManager implements INavigationManager {
 			$this->add([
 				'type' => 'settings',
 				'id' => 'help',
-				'order' => 5,
+				'order' => 6,
 				'href' => $this->urlGenerator->linkToRoute('settings.Help.help'),
 				'name' => $l->t('Help'),
 				'icon' => $this->urlGenerator->imagePath('settings', 'help.svg'),
@@ -213,7 +213,7 @@ class NavigationManager implements INavigationManager {
 				$this->add([
 					'type' => 'settings',
 					'id' => 'core_apps',
-					'order' => 3,
+					'order' => 4,
 					'href' => $this->urlGenerator->linkToRoute('settings.AppSettings.viewApps'),
 					'icon' => $this->urlGenerator->imagePath('settings', 'apps.svg'),
 					'name' => $l->t('Apps'),
@@ -224,7 +224,7 @@ class NavigationManager implements INavigationManager {
 			$this->add([
 				'type' => 'settings',
 				'id' => 'settings',
-				'order' => 1,
+				'order' => 2,
 				'href' => $this->urlGenerator->linkToRoute('settings.PersonalSettings.index'),
 				'name' => $l->t('Settings'),
 				'icon' => $this->urlGenerator->imagePath('settings', 'admin.svg'),
@@ -248,7 +248,7 @@ class NavigationManager implements INavigationManager {
 				$this->add([
 					'type' => 'settings',
 					'id' => 'core_users',
-					'order' => 4,
+					'order' => 5,
 					'href' => $this->urlGenerator->linkToRoute('settings.Users.usersList'),
 					'name' => $l->t('Users'),
 					'icon' => $this->urlGenerator->imagePath('settings', 'users.svg'),

+ 101 - 0
lib/public/User/Events/UserLiveStatusEvent.php

@@ -0,0 +1,101 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCP\User\Events;
+
+use OCP\EventDispatcher\Event;
+use OCP\IUser;
+
+/**
+ * @since 20.0.0
+ */
+class UserLiveStatusEvent extends Event {
+
+	/**
+	 * @var string
+	 * @since 20.0.0
+	 */
+	public const STATUS_ONLINE = 'online';
+
+	/**
+	 * @var string
+	 * @since 20.0.0
+	 */
+	public const STATUS_AWAY = 'away';
+
+	/**
+	 * @var string
+	 * @since 20.0.0
+	 */
+	public const STATUS_OFFLINE = 'offline';
+
+	/** @var IUser */
+	private $user;
+
+	/** @var string */
+	private $status;
+
+	/** @var int */
+	private $timestamp;
+
+	/**
+	 * @param IUser $user
+	 * @param string $status
+	 * @param int $timestamp
+	 * @since 20.0.0
+	 */
+	public function __construct(IUser $user,
+								string $status,
+								int $timestamp) {
+		parent::__construct();
+		$this->user = $user;
+		$this->status = $status;
+		$this->timestamp = $timestamp;
+	}
+
+	/**
+	 * @return IUser
+	 * @since 20.0.0
+	 */
+	public function getUser(): IUser {
+		return $this->user;
+	}
+
+	/**
+	 * @return string
+	 * @since 20.0.0
+	 */
+	public function getStatus(): string {
+		return $this->status;
+	}
+
+	/**
+	 * @return int
+	 * @since 20.0.0
+	 */
+	public function getTimestamp(): int {
+		return $this->timestamp;
+	}
+}

+ 56 - 0
package-lock.json

@@ -1586,6 +1586,57 @@
         "core-js": "^3.6.4"
       }
     },
+    "@nextcloud/moment": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@nextcloud/moment/-/moment-1.1.1.tgz",
+      "integrity": "sha512-lh7Xn9Ver12pLfE0rpjxE6x/ipscAV+7fw1u+7TJak1QR1T1UDRMZ9dA7z77W8mZH2C3yveTh/VEHZIflKBrng==",
+      "requires": {
+        "@nextcloud/l10n": "1.2.0",
+        "core-js": "3.6.4",
+        "jed": "^1.1.1",
+        "moment": "2.24.0",
+        "node-gettext": "^2.0.0"
+      },
+      "dependencies": {
+        "@nextcloud/l10n": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-1.2.0.tgz",
+          "integrity": "sha512-aPsVAewCYMNe2h0yse3Fj7LofvnvFPimojw24K47ip1+I1gawMIsQL+BYAnN8wzlcbsDTEc7I1FxtOh+8dHHIA==",
+          "requires": {
+            "core-js": "^3.6.4",
+            "node-gettext": "^3.0.0"
+          },
+          "dependencies": {
+            "node-gettext": {
+              "version": "3.0.0",
+              "resolved": "https://registry.npmjs.org/node-gettext/-/node-gettext-3.0.0.tgz",
+              "integrity": "sha512-/VRYibXmVoN6tnSAY2JWhNRhWYJ8Cd844jrZU/DwLVoI4vBI6ceYbd8i42sYZ9uOgDH3S7vslIKOWV/ZrT2YBA==",
+              "requires": {
+                "lodash.get": "^4.4.2"
+              }
+            }
+          }
+        },
+        "core-js": {
+          "version": "3.6.4",
+          "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz",
+          "integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw=="
+        },
+        "moment": {
+          "version": "2.24.0",
+          "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
+          "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
+        },
+        "node-gettext": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/node-gettext/-/node-gettext-2.1.0.tgz",
+          "integrity": "sha512-vsHImHl+Py0vB7M2UXcFEJ5NJ3950gcja45YclBFtYxYeZiqdfQdcu+G9s4L7jpRFSh/J/7VoS3upR4JM1nS+g==",
+          "requires": {
+            "lodash.get": "^4.4.2"
+          }
+        }
+      }
+    },
     "@nextcloud/password-confirmation": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/@nextcloud/password-confirmation/-/password-confirmation-1.0.1.tgz",
@@ -6033,6 +6084,11 @@
         "resize-observer-polyfill": "^1.5.0"
       }
     },
+    "jed": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/jed/-/jed-1.1.1.tgz",
+      "integrity": "sha1-elSbvZ/+FYWwzQoZHiAwVb7ldLQ="
+    },
     "jquery": {
       "version": "2.2.4",
       "resolved": "https://registry.npmjs.org/jquery/-/jquery-2.2.4.tgz",

+ 1 - 0
package.json

@@ -35,6 +35,7 @@
     "@nextcloud/initial-state": "^1.1.2",
     "@nextcloud/l10n": "^1.3.0",
     "@nextcloud/logger": "^1.1.2",
+    "@nextcloud/moment": "^1.1.1",
     "@nextcloud/password-confirmation": "^1.0.1",
     "@nextcloud/paths": "^1.1.2",
     "@nextcloud/router": "^1.1.0",

+ 2 - 2
tests/lib/NavigationManagerTest.php

@@ -244,7 +244,7 @@ class NavigationManagerTest extends TestCase {
 		$apps = [
 			'core_apps' => [
 				'id'      => 'core_apps',
-				'order'   => 3,
+				'order'   => 4,
 				'href'    => '/apps/test/',
 				'icon'    => '/apps/settings/img/apps.svg',
 				'name'    => 'Apps',
@@ -256,7 +256,7 @@ class NavigationManagerTest extends TestCase {
 		$defaults = [
 			'settings' => [
 				'id'      => 'settings',
-				'order'   => 1,
+				'order'   => 2,
 				'href'    => '/apps/test/',
 				'icon'    => '/apps/settings/img/admin.svg',
 				'name'    => 'Settings',

+ 2 - 0
webpack.common.js

@@ -15,6 +15,7 @@ const files_versions = require('./apps/files_versions/webpack')
 const oauth2 = require('./apps/oauth2/webpack')
 const settings = require('./apps/settings/webpack')
 const systemtags = require('./apps/systemtags/webpack')
+const user_status = require('./apps/user_status/webpack')
 const twofactor_backupscodes = require('./apps/twofactor_backupcodes/webpack')
 const updatenotification = require('./apps/updatenotification/webpack')
 const workflowengine = require('./apps/workflowengine/webpack')
@@ -31,6 +32,7 @@ const modules = {
 	oauth2,
 	settings,
 	systemtags,
+	user_status,
 	twofactor_backupscodes,
 	updatenotification,
 	workflowengine

Some files were not shown because too many files changed in this diff