Skip to content
Closed
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
12 changes: 11 additions & 1 deletion docs/participant.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,28 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1`
field | type | Description
------|------|------------
`password` | string | Optional: Password is only required for users which are of type `4` or `5` and only when the conversation has `hasPassword` set to true.
`force` | bool | If set to `false` and the user has an active session already a `409 Conflict` will be returned (Default: true - to keep the old behaviour)

* Response:
- Status code:
+ `200 OK`
+ `403 Forbidden` When the password is required and didn't match
+ `404 Not Found` When the conversation could not be found for the participant
+ `409 Conflict` When the user already has an active session in the conversation. The suggested behaviour is to ask the user whether they want to kill the old session and force join unless the last ping is older than 60 seconds or older than 40 seconds when the conflicting session is not marked as in a call.

- Data:
- Data in case of `200 OK`:

field | type | Description
------|------|------------
`sessionId` | string | 512 character long string

- Data in case of `409 Conflict`:

field | type | Description
------|------|------------
`sessionId` | string | 512 character long string
`inCall` | int | Flags whether the conflicting session is in a potential call
`lastPing` | int | Timestamp of the last ping of the conflicting session

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

Expand Down
36 changes: 34 additions & 2 deletions lib/Controller/RoomController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1144,15 +1144,47 @@ public function setPassword(string $password): DataResponse {
*
* @param string $token
* @param string $password
* @param bool $force
* @return DataResponse
*/
public function joinRoom(string $token, string $password = ''): DataResponse {
public function joinRoom(string $token, string $password = '', bool $force = true): DataResponse {
try {
$room = $this->manager->getRoomForParticipantByToken($token, $this->userId);
} catch (RoomNotFoundException $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}

$previousSession = null;
if ($this->userId !== null) {
try {
$previousSession = $room->getParticipant($this->userId);
} catch (ParticipantNotFoundException $e) {
}
} else {
$session = $this->session->getSessionForRoom($token);
try {
$previousSession = $room->getParticipantBySession($session);
} catch (ParticipantNotFoundException $e) {
}
}

if ($previousSession instanceof Participant && $previousSession->getSessionId() !== '0') {
// Previous session was active
if ($force === false) {
return new DataResponse([
'sessionId' => $previousSession->getSessionId(),
'inCall' => $previousSession->getInCallFlags(),
'lastPing' => $previousSession->getLastPing(),
], Http::STATUS_CONFLICT);
}

if ($this->userId === null) {
$room->removeParticipantBySession($previousSession, Room::PARTICIPANT_LEFT);
} else {
$room->leaveRoomAsParticipant($previousSession);
}
}

$user = $this->userManager->get($this->userId);
try {
$result = $room->verifyPassword((string) $this->session->getPasswordForRoom($token));
Expand Down Expand Up @@ -1194,7 +1226,7 @@ public function leaveRoom(string $token): DataResponse {
$room->removeParticipantBySession($participant, Room::PARTICIPANT_LEFT);
} else {
$participant = $room->getParticipant($this->userId);
$room->leaveRoom($participant->getUser());
$room->leaveRoomAsParticipant($participant);
}
} catch (RoomNotFoundException $e) {
} catch (ParticipantNotFoundException $e) {
Expand Down
12 changes: 11 additions & 1 deletion lib/Controller/SignalingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,17 @@ public function pullMessages(string $token): DataResponse {
$data[] = ['type' => 'usersInRoom', 'data' => $this->getUsersInRoom($room, $pingTimestamp)];
} catch (RoomNotFoundException $e) {
$data[] = ['type' => 'usersInRoom', 'data' => []];
return new DataResponse($data, Http::STATUS_NOT_FOUND);

// Was the session killed or the complete conversation?
try {
$this->manager->getRoomForParticipantByToken($token, $this->userId);

// Session was killed, make the UI redirect to an error
return new DataResponse($data, Http::STATUS_CONFLICT);
} catch (RoomNotFoundException $e) {
// Complete conversation was killed, bye!
return new DataResponse($data, Http::STATUS_NOT_FOUND);
}
}

return new DataResponse($data);
Expand Down
19 changes: 13 additions & 6 deletions lib/Room.php
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,13 @@ public function leaveRoom(string $userId, ?string $sessionId = null): void {
return;
}

$this->leaveRoomAsParticipant($participant);
}

/**
* @param Participant $participant
*/
public function leaveRoomAsParticipant(Participant $participant): void {
$event = new ParticipantEvent($this, $participant);
$this->dispatcher->dispatch(self::EVENT_BEFORE_ROOM_DISCONNECT, $event);

Expand All @@ -847,22 +854,22 @@ public function leaveRoom(string $userId, ?string $sessionId = null): void {
$query->update('talk_participants')
->set('session_id', $query->createNamedParameter('0'))
->set('in_call', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT))
->where($query->expr()->eq('user_id', $query->createNamedParameter($userId)))
->where($query->expr()->eq('user_id', $query->createNamedParameter($participant->getUser())))
->andWhere($query->expr()->eq('room_id', $query->createNamedParameter($this->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->neq('participant_type', $query->createNamedParameter(Participant::USER_SELF_JOINED, IQueryBuilder::PARAM_INT)));
if (!empty($sessionId)) {
$query->andWhere($query->expr()->eq('session_id', $query->createNamedParameter($sessionId)));
if ($participant->getSessionId() !== '0') {
$query->andWhere($query->expr()->eq('session_id', $query->createNamedParameter($participant->getSessionId())));
}
$query->execute();

// And kill session when leaving a self joined room
$query = $this->db->getQueryBuilder();
$query->delete('talk_participants')
->where($query->expr()->eq('user_id', $query->createNamedParameter($userId)))
->where($query->expr()->eq('user_id', $query->createNamedParameter($participant->getUser())))
->andWhere($query->expr()->eq('room_id', $query->createNamedParameter($this->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('participant_type', $query->createNamedParameter(Participant::USER_SELF_JOINED, IQueryBuilder::PARAM_INT)));
if (!empty($sessionId)) {
$query->andWhere($query->expr()->eq('session_id', $query->createNamedParameter($sessionId)));
if ($participant->getSessionId() !== '0') {
$query->andWhere($query->expr()->eq('session_id', $query->createNamedParameter($participant->getSessionId())));
}
$query->execute();

Expand Down
30 changes: 30 additions & 0 deletions src/services/SessionStorage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* @copyright Copyright (c) 2020 Joas Schilling <[email protected]>
*
* @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 { getBuilder } from '@nextcloud/browser-storage'

/**
* Note: This uses the browsers sessionStorage not the browserStorage.
* As per https://stackoverflow.com/q/20325763 this is NOT shared between tabs.
* For us this is the solution we were looking for, as it allows us to
* identify if a user reloaded a conversation in the same tab,
* or entered it in another tab.
*/
export default getBuilder('talk').clearOnLogout().build()
62 changes: 59 additions & 3 deletions src/services/participantsService.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@
*/

import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import {
generateUrl,
generateOcsUrl,
} from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'
import {
signalingJoinConversation,
signalingLeaveConversation,
} from '../utils/webrtc/index'
import { EventBus } from './EventBus'
import SessionStorage from './SessionStorage'
import { PARTICIPANT } from '../constants'

/**
* Joins the current user to a conversation specified with
Expand All @@ -35,17 +41,67 @@ import { EventBus } from './EventBus'
* @param {string} token The conversation token;
*/
const joinConversation = async(token) => {
// When the token is in the last joined conversation, the user is reloading or force joining
const forceJoin = SessionStorage.getItem('joined_conversation') === token

try {
const response = await axios.post(generateOcsUrl('apps/spreed/api/v2', 2) + `room/${token}/participants/active`)
const response = await axios.post(generateOcsUrl('apps/spreed/api/v2', 2) + `room/${token}/participants/active`, {
force: forceJoin,
})

// FIXME Signaling should not be synchronous
await signalingJoinConversation(token, response.data.ocs.data.sessionId)
SessionStorage.setItem('joined_conversation', token)
EventBus.$emit('joinedConversation')
return response
} catch (error) {
console.debug(error)
if (error.response.status === 409) {
const responseData = error.response.data.ocs.data
let maxLastPingAge = new Date().getTime() / 1000 - 40
if (responseData.inCall !== PARTICIPANT.CALL_FLAG.DISCONNECTED) {
// When the user is/was in a call, we accept 20 seconds more delay
maxLastPingAge -= 20
}
if (maxLastPingAge > responseData.lastPing) {
console.debug('Force joining automatically because the old session didn\'t ping for 40 seconds')
await forceJoinConversation(token)
} else {
await confirmForceJoinConversation(token)
}
} else {
console.debug(error)
showError(t('spreed', 'Failed to join the conversation. Try to reload the page.'))
}
}
}

const confirmForceJoinConversation = async(token) => {
await OC.dialogs.confirmDestructive(
t('spreed', 'You are trying to join a conversation while having an active session in another window or device. This is currently not supported by Nextcloud Talk. What do you want to do?'),
t('spreed', 'Duplicate session'),
{
type: OC.dialogs.YES_NO_BUTTONS,
confirm: t('spreed', 'Join here'),
confirmClasses: 'error',
cancel: t('spreed', 'Leave this page'),
},
decision => {
if (!decision) {
// Cancel
window.location = generateUrl('/apps/spreed')
} else {
// Confirm
forceJoinConversation(token)
}
}
)
}

const forceJoinConversation = async(token) => {
SessionStorage.setItem('joined_conversation', token)
await joinConversation(token)
}

/**
* Joins the current user to a conversation specified with
* the token.
Expand Down
63 changes: 54 additions & 9 deletions src/utils/signaling.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@ import { rejoinConversation } from '../services/participantsService'
import CancelableRequest from './cancelableRequest'
import { EventBus } from '../services/EventBus'
import axios from '@nextcloud/axios'
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import {
generateOcsUrl,
generateUrl,
} from '@nextcloud/router'
import {
showError,
showWarning,
} from '@nextcloud/dialogs'
import SessionStorage from '../services/SessionStorage'

const Signaling = {
Base: {},
Expand Down Expand Up @@ -292,6 +296,7 @@ function Internal(settings) {
this.hideWarning = settings.hideWarning
this.spreedArrayConnection = []

this.pullMessageErrorToast = null
this.pullMessagesFails = 0
this.pullMessagesRequest = null

Expand Down Expand Up @@ -405,6 +410,11 @@ Signaling.Internal.prototype._startPullingMessages = function() {
request(token)
.then(function(result) {
this.pullMessagesFails = 0
if (this.pullMessageErrorToast) {
this.pullMessageErrorToast.hideToast()
this.pullMessageErrorToast = null
}

result.data.ocs.data.forEach(message => {
this._trigger('onBeforeReceiveMessage', [message])
switch (message.type) {
Expand All @@ -431,23 +441,58 @@ Signaling.Internal.prototype._startPullingMessages = function() {
// User navigated away in the meantime. Ignore
} else if (axios.isCancel(error)) {
console.debug('Pulling messages request was cancelled')
} else if (error.response && (error.response.status === 404 || error.response.status === 403)) {
console.error('Stop pulling messages because room does not exist or is not accessible')
} else if (error.response && error.response.status === 409) {
// Participant joined a second time and this session was killed
console.error('Session was killed but the conversation still exists')
this._trigger('pullMessagesStoppedOnFail')
} else if (token) {
if (this.pullMessagesFails >= 3) {
console.error('Stop pulling messages after repeated failures')

this._trigger('pullMessagesStoppedOnFail')
OC.dialogs.confirmDestructive(
t('spreed', 'You joined the conversation in another window or device. This is currently not supported by Nextcloud Talk. What do you want to do?'),
t('spreed', 'Duplicate session'),
{
type: OC.dialogs.YES_NO_BUTTONS,
confirm: t('spreed', 'Restart here'),
confirmClasses: 'error',
cancel: t('spreed', 'Leave this page'),
},
decision => {
if (!decision) {
// Cancel
SessionStorage.removeItem('joined_conversation')
window.location = generateUrl('/apps/spreed')
} else {
// Confirm
window.location = generateUrl('call/' + token)
}
}
)
} else if (error.response && (error.response.status === 404 || error.response.status === 403)) {
// Conversation was deleted or the user was removed
console.error('Conversation was not found anymore')
OC.redirect(generateUrl('/apps/spreed/not-found'))
} else if (token) {
if (this.pullMessagesFails === 1) {
this.pullMessageErrorToast = showError(t('spreed', 'Lost connection to signaling server. Trying to reconnect.'), {
timeout: -1,
})
}
if (this.pullMessagesFails === 30) {
if (this.pullMessageErrorToast) {
this.pullMessageErrorToast.hideToast()
}

// Giving up after 5 minutes
this.pullMessageErrorToast = showError(t('spreed', 'Lost connection to signaling server. Try to reload the page manually.'), {
timeout: -1,
})
return
}

this.pullMessagesFails++
// Retry to pull messages after 5 seconds
// Retry to pull messages after 10 seconds
window.setTimeout(function() {
this._startPullingMessages()
}.bind(this), 5000)
}.bind(this), 10000)
}
}.bind(this))
}
Expand Down