Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(notifications): Allow sessions to mark themselves as inactive #10544

Merged
merged 2 commits into from
Sep 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ And in the works for the [coming versions](https://github.com/nextcloud/spreed/m

]]></description>

<version>18.0.0-dev.5</version>
<version>18.0.0-dev.6</version>
<licence>agpl</licence>

<author>Daniel Calviño Sánchez</author>
Expand Down
2 changes: 2 additions & 0 deletions appinfo/routes/routesRoomController.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@
['name' => 'Room#resendInvitations', 'url' => '/api/{apiVersion}/room/{token}/participants/resend-invitations', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
/** @see \OCA\Talk\Controller\RoomController::leaveRoom() */
['name' => 'Room#leaveRoom', 'url' => '/api/{apiVersion}/room/{token}/participants/active', 'verb' => 'DELETE', 'requirements' => $requirementsWithToken],
/** @see \OCA\Talk\Controller\RoomController::setSessionState() */
['name' => 'Room#setSessionState', 'url' => '/api/{apiVersion}/room/{token}/participants/state', 'verb' => 'PUT', 'requirements' => $requirementsWithToken],
/** @see \OCA\Talk\Controller\RoomController::promoteModerator() */
['name' => 'Room#promoteModerator', 'url' => '/api/{apiVersion}/room/{token}/moderators', 'verb' => 'POST', 'requirements' => $requirementsWithToken],
/** @see \OCA\Talk\Controller\RoomController::demoteModerator() */
Expand Down
3 changes: 3 additions & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,6 @@
* `remind-me-later` - Support for "Remind me later" for chat messages exists
* `bots-v1` - Support of the first version for Bots and Webhooks is available
* `markdown-messages` - Chat messages support markdown and are rendered automatically

## 18
* `session-state` - Sessions can mark themselves as inactive, so the participant receives notifications again
4 changes: 4 additions & 0 deletions docs/constants.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@
* `bots` - Used by commands (actor-id is the used `/command`) and the changelog conversation (actor-id is `changelog`)
* `bridged` - Users whose messages are bridged in by the [Matterbridge integration](matterbridge.md)

### Session states
* `0` - Inactive (Notifications should still be sent, even though the user has this session in the room)
* `1` - Active (No notifications should be sent)

## Call

### Start call
Expand Down
12 changes: 12 additions & 0 deletions docs/participant.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,18 @@
+ `403 Forbidden` When the current user is not a moderator or owner
+ `404 Not Found` When the given attendee was not found in the conversation

## Set session state

* Required capability: `session-state`
* Method: `PUT`
* Endpoint: `/room/{token}/participants/state`

* Response:
- Status code:
+ `200 OK`
+ `400 Bad Request` When the provided state is invalid (see [constants list](constants.md#participant-state))
+ `404 Not Found` When the conversation could not be found for the participant

## Leave a conversation (not available for call and chat anymore)

* Method: `DELETE`
Expand Down
1 change: 1 addition & 0 deletions lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ public function getCapabilities(): array {
'remind-me-later',
'bots-v1',
'markdown-messages',
'session-state',
],
'config' => [
'attachments' => [
Expand Down
12 changes: 12 additions & 0 deletions lib/Controller/RoomController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,18 @@ public function createGuestByDialIn(): DataResponse {
return new DataResponse($this->formatRoom($this->room, $participant));
}

#[PublicPage]
#[RequireParticipant]
public function setSessionState(int $state): DataResponse {
try {
$this->sessionService->updateSessionState($this->participant->getSession(), $state);
} catch (\InvalidArgumentException $e) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}

return new DataResponse();
}

#[PublicPage]
public function leaveRoom(string $token): DataResponse {
$sessionId = $this->session->getSessionForRoom($token);
Expand Down
55 changes: 55 additions & 0 deletions lib/Migration/Version18000Date20230920182747.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);
/**
* @copyright Copyright (c) 2023, Joas Schilling <coding@schilljs.com>
*
* @author Joas Schilling <coding@schilljs.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/>.
*
*/

namespace OCA\Talk\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version18000Date20230920182747 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

$table = $schema->getTable('talk_sessions');
if (!$table->hasColumn('state')) {
$table->addColumn('state', Types::SMALLINT, [
'default' => 1, // active
'unsigned' => true,
]);
return $schema;
}
return null;
}
}
13 changes: 9 additions & 4 deletions lib/Model/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,6 @@
* A session is the "I'm online in this conversation" state of Talk, you get one
* when opening the conversation while the inCall flag tells if you are just
* online (chatting), or in a call (with audio, camera or even sip).
* Currently it's limited to 1 per attendee, but the plan is to remove this
* restriction in the future, so e.g. in the future you can join with your phone
* on the SIP bridge, have your video/screenshare on the laptop and chat in the
* mobile app.
*
* @method void setAttendeeId(int $attendeeId)
* @method string getAttendeeId()
Expand All @@ -42,8 +38,13 @@
* @method int getInCall()
* @method void setLastPing(int $lastPing)
* @method int getLastPing()
* @method void setState(int $state)
* @method int getState()
*/
class Session extends Entity {
public const STATE_INACTIVE = 0;
public const STATE_ACTIVE = 1;

public const SESSION_TIMEOUT = 30;
public const SESSION_TIMEOUT_KILL = self::SESSION_TIMEOUT * 3 + 10;

Expand All @@ -59,11 +60,15 @@ class Session extends Entity {
/** @var int */
protected $lastPing;

/** @var int */
protected $state;

public function __construct() {
$this->addType('attendeeId', 'int');
$this->addType('sessionId', 'string');
$this->addType('inCall', 'int');
$this->addType('lastPing', 'int');
$this->addType('state', 'int');
}

/**
Expand Down
7 changes: 5 additions & 2 deletions lib/Service/ParticipantService.php
Original file line number Diff line number Diff line change
Expand Up @@ -1358,10 +1358,13 @@ public function getParticipantsByNotificationLevel(Room $room, int $notification
$helper->selectAttendeesTable($query);
$helper->selectSessionsTable($query);
$query->from('talk_attendees', 'a')
// Currently we only care if the user has a session at all, so we can select any: #ThisIsFine
// Currently we only care if the user has an active session at all, so we can select any
->leftJoin(
'a', 'talk_sessions', 's',
$query->expr()->eq('s.attendee_id', 'a.id')
$query->expr()->andX(
$query->expr()->eq('s.attendee_id', 'a.id'),
$query->expr()->eq('s.state', $query->createNamedParameter(Session::STATE_ACTIVE, IQueryBuilder::PARAM_INT))
)
)
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('a.notification_level', $query->createNamedParameter($notificationLevel, IQueryBuilder::PARAM_INT)));
Expand Down
12 changes: 12 additions & 0 deletions lib/Service/SessionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ public function updateLastPing(Session $session, int $lastPing): void {
$this->sessionMapper->update($session);
}

/**
* @throws \InvalidArgumentException
*/
public function updateSessionState(Session $session, int $state): void {
if (!in_array($state, [Session::STATE_INACTIVE, Session::STATE_ACTIVE], true)) {
throw new \InvalidArgumentException('state');
}

$session->setState($state);
$this->sessionMapper->update($session);
}

/**
* @param int[] $ids
*/
Expand Down
5 changes: 4 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import MediaSettings from './components/MediaSettings/MediaSettings.vue'
import RightSidebar from './components/RightSidebar/RightSidebar.vue'
import SettingsDialog from './components/SettingsDialog/SettingsDialog.vue'

import { useActiveSession } from './composables/useActiveSession.js'
import { useIsInCall } from './composables/useIsInCall.js'
import { CONVERSATION, PARTICIPANT } from './constants.js'
import browserCheck from './mixins/browserCheck.js'
Expand Down Expand Up @@ -92,7 +93,9 @@ export default {

setup() {
const isInCall = useIsInCall()
return { isInCall }
const supportSessionState = useActiveSession()

return { isInCall, supportSessionState }
},

data() {
Expand Down
134 changes: 134 additions & 0 deletions src/composables/useActiveSession.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* @copyright Copyright (c) 2023 Maksim Sukharev <antreesy.web@gmail.com>
*
* @license AGPL-3.0-or-later
*
* 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 { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'

import { getCapabilities } from '@nextcloud/capabilities'
import { showInfo } from '@nextcloud/dialogs'

import { SESSION } from '../constants.js'
import { setSessionState } from '../services/participantsService.js'
import { useStore } from './useStore.js'

const supportSessionState = getCapabilities()?.spreed?.features?.includes('session-state')
const INACTIVE_TIME_MS = 3 * 60 * 1000
/**
* Check whether the current session is active or not:
* - tab or browser window was moved to background or minimized
* - there was no movement within tab window for a long time
* - work for both ChatView and CallView
*
* @return {boolean|undefined}
*/
export function useActiveSession() {
if (!supportSessionState) {
return supportSessionState
}

const store = useStore()
const token = computed(() => store.getters.getToken())
const windowIsVisible = computed(() => store.getters.windowIsVisible())

const inactiveTimer = ref(null)
const currentState = ref(SESSION.STATE.ACTIVE)

watch(token, () => {
// Joined conversation has active state by default
currentState.value = SESSION.STATE.ACTIVE
})

watch(windowIsVisible, (value) => {
// Change state if tab is hidden or minimized
if (value) {
setSessionAsActive()
} else {
setSessionAsInactive()
}
})

onBeforeMount(() => {
window.addEventListener('focus', handleWindowFocus)
window.addEventListener('blur', handleWindowFocus)
})

onBeforeUnmount(() => {
window.removeEventListener('focus', handleWindowFocus)
window.removeEventListener('blur', handleWindowFocus)
})

const setSessionAsActive = async () => {
if (currentState.value === SESSION.STATE.ACTIVE) {
return
}
clearTimeout(inactiveTimer.value)
inactiveTimer.value = null
currentState.value = SESSION.STATE.ACTIVE

try {
await setSessionState(token.value, SESSION.STATE.ACTIVE)
Antreesy marked this conversation as resolved.
Show resolved Hide resolved
console.info('Session has been marked as active')
} catch (error) {
console.error(error)
}
}

const setSessionAsInactive = async () => {
if (currentState.value === SESSION.STATE.INACTIVE) {
return
}
clearTimeout(inactiveTimer.value)
inactiveTimer.value = null
currentState.value = SESSION.STATE.INACTIVE

try {
await setSessionState(token.value, SESSION.STATE.INACTIVE)
showInfo(t('spreed', 'Session has been marked as inactive'))
Antreesy marked this conversation as resolved.
Show resolved Hide resolved
console.info('Session has been marked as inactive')
} catch (error) {
console.error(error)
}
}

const handleWindowFocus = ({ type }) => {
if (type === 'focus') {
setSessionAsActive()

document.removeEventListener('mouseenter', handleMouseMove)
document.removeEventListener('mouseleave', handleMouseMove)
} else if (type === 'blur') {
inactiveTimer.value = setTimeout(() => {
setSessionAsInactive()
}, INACTIVE_TIME_MS)

// Listen for mouse events to track activity on tab
document.addEventListener('mouseenter', handleMouseMove)
document.addEventListener('mouseleave', handleMouseMove)
}
}

const handleMouseMove = (event) => {
// Restart timer, if mouse moves around the tab
clearTimeout(inactiveTimer.value)
inactiveTimer.value = setTimeout(() => {
setSessionAsInactive()
}, INACTIVE_TIME_MS)
}

return supportSessionState
}
8 changes: 8 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export const SIGNALING = {
CLUSTER_CONVERSATION: 'conversation_cluster',
},
}

export const SESSION = {
STATE: {
INACTIVE: 0,
ACTIVE: 1,
},
}

export const CHAT = {
FETCH_LIMIT: 100,
MINIMUM_VISIBLE: 5,
Expand Down
1 change: 1 addition & 0 deletions src/services/messagesService.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const lookForNewMessages = async ({ token, lastKnownMessageId, limit = 100 }, op
lastKnownMessageId,
limit,
includeLastKnown: 0,
markNotificationsAsRead: 0,
},
}))
}
Expand Down
1 change: 1 addition & 0 deletions src/services/messagesService.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ describe('messagesService', () => {
lastKnownMessageId: 1234,
limit: CHAT.FETCH_LIMIT,
includeLastKnown: 0,
markNotificationsAsRead: 0,
},
}
)
Expand Down
Loading
Loading