Merge pull request #281 from arawa/feature/share_moderators_and_users

Feature/share moderators and users
pull/285/head
Thibaut 2024-09-03 09:34:02 +02:00 committed by GitHub
commit 29aa147ce6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 228 additions and 61 deletions

View File

@ -14,6 +14,7 @@ return [
['name' => 'server#check', 'url' => '/server/check', 'verb' => 'POST'],
['name' => 'server#version', 'url' => '/server/version', 'verb' => 'GET'],
['name' => 'server#delete_record', 'url' => '/server/record/{recordId}', 'verb' => 'DELETE'],
['name' => 'server#publish_record', 'url' => '/server/record/{recordId}/publish', 'verb' => 'POST'],
['name' => 'join#index', 'url' => '/b/{token}/{moderatorToken}', 'verb' => 'GET', 'defaults' => ['moderatorToken' => '']],
['name' => 'restriction#user', 'url' => '/restrictions/user', 'verb' => 'GET'],
['name' => 'hook#meetingEnded', 'url' => '/hook/ended/{token}/{mac}', 'verb' => 'GET'],

View File

@ -10,6 +10,7 @@ use BigBlueButton\Parameters\GetRecordingsParameters;
use BigBlueButton\Parameters\InsertDocumentParameters;
use BigBlueButton\Parameters\IsMeetingRunningParameters;
use BigBlueButton\Parameters\JoinMeetingParameters;
use BigBlueButton\Parameters\PublishRecordingsParameters;
use OCA\BigBlueButton\AppInfo\Application;
use OCA\BigBlueButton\AvatarRepository;
use OCA\BigBlueButton\Crypto;
@ -262,6 +263,14 @@ class API {
return $response->isDeleted();
}
public function publishRecording(string $recordingId, bool $published): bool {
$publishParams = new PublishRecordingsParameters($recordingId, $published);
$response = $this->getServer()->publishRecordings($publishParams);
return $response->isPublished();
}
/**
* @return (array|bool|int|string)[]
*

View File

@ -11,6 +11,7 @@ use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUserManager;
@ -24,6 +25,9 @@ class RoomShareController extends Controller {
/** @var IUserManager */
private $userManager;
/** @var IGroupManager */
private $groupManager;
/** @var RoomService */
private $roomService;
@ -37,6 +41,7 @@ class RoomShareController extends Controller {
IRequest $request,
RoomShareService $service,
IUserManager $userManager,
IGroupManager $groupManager,
RoomService $roomService,
CircleHelper $circleHelper,
$userId
@ -44,6 +49,7 @@ class RoomShareController extends Controller {
parent::__construct($appName, $request);
$this->service = $service;
$this->userManager = $userManager;
$this->groupManager = $groupManager;
$this->roomService = $roomService;
$this->circleHelper = $circleHelper;
$this->userId = $userId;
@ -90,6 +96,14 @@ class RoomShareController extends Controller {
}
$roomShare->setShareWithDisplayName($circle->getName());
} elseif ($roomShare->getShareType() === RoomShare::SHARE_TYPE_GROUP) {
$shareWithGroup = $this->groupManager->get($roomShare->getShareWith());
if ($shareWithGroup === null) {
continue;
}
$roomShare->setShareWithDisplayName($shareWithGroup->getDisplayName());
}
$shares[] = $roomShare;

View File

@ -88,12 +88,18 @@ class ServerController extends Controller {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
if (!$this->permission->isAdmin($room, $this->userId)) {
if (!$this->permission->isUser($room, $this->userId)) {
return new DataResponse([], Http::STATUS_FORBIDDEN);
}
$recordings = $this->server->getRecordings($room);
if (!$this->permission->isAdmin($room, $this->userId)) {
$recordings = array_values(array_filter($recordings, function ($recording) {
return $recording['published'];
}));
}
return new DataResponse($recordings);
}
@ -118,6 +124,27 @@ class ServerController extends Controller {
return new DataResponse($success);
}
/**
* @NoAdminRequired
*/
public function publishRecord(string $recordId, bool $published): DataResponse {
$record = $this->server->getRecording($recordId);
$room = $this->service->findByUid($record['meetingId']);
if ($room === null) {
return new DataResponse(false, Http::STATUS_NOT_FOUND);
}
if (!$this->permission->isAdmin($room, $this->userId)) {
return new DataResponse(false, Http::STATUS_FORBIDDEN);
}
$success = $this->server->publishRecording($recordId, $published);
return new DataResponse($success);
}
public function check(?string $url, ?string $secret): DataResponse {
if ($url === null || empty($url) || $secret === null || empty($secret)) {
return new DataResponse(false);

View File

@ -74,6 +74,7 @@ class Room extends Entity implements JsonSerializable {
public $cleanLayout;
public $joinMuted;
public $running;
public $permission;
public function __construct() {
$this->addType('maxParticipants', 'integer');
@ -86,6 +87,7 @@ class Room extends Entity implements JsonSerializable {
$this->addType('cleanLayout', 'boolean');
$this->addType('joinMuted', 'boolean');
$this->addType('running', 'boolean');
$this->addType('permission', 'integer');
}
public function jsonSerialize(): array {
@ -102,6 +104,7 @@ class Room extends Entity implements JsonSerializable {
'everyoneIsModerator' => boolval($this->everyoneIsModerator),
'requireModerator' => boolval($this->requireModerator),
'shared' => boolval($this->shared),
'permission' => $this->permission,
'moderatorToken' => $this->moderatorToken,
'listenOnly' => boolval($this->listenOnly),
'mediaCheck' => boolval($this->mediaCheck),

View File

@ -12,6 +12,16 @@ class RoomMapper extends QBMapper {
parent::__construct($db, 'bbb_rooms', Room::class);
}
private function joinShares(IQueryBuilder $qb): IQueryBuilder {
$qb->select('r.*')
->from($this->tableName, 'r')
->leftJoin('r', 'bbb_room_shares', 's', $qb->expr()->eq('r.id', 's.room_id'))
->addSelect($qb->createFunction('count(case when `s`.`permission` IN ('.
RoomShare::PERMISSION_ADMIN.','.RoomShare::PERMISSION_MODERATOR.','.RoomShare::PERMISSION_USER
.') then 1 else null end) as shared'));
return $qb;
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
@ -19,10 +29,7 @@ class RoomMapper extends QBMapper {
public function find(int $id): Room {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('r.*')
->from($this->tableName, 'r')
->leftJoin('r', 'bbb_room_shares', 's', $qb->expr()->eq('r.id', 's.room_id'))
->addSelect($qb->createFunction('count(case when `s`.`permission` = 0 then 1 else null end) as shared'))
$this->joinShares($qb)
->where($qb->expr()->eq('r.id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
->groupBy('r.id');
;
@ -38,10 +45,7 @@ class RoomMapper extends QBMapper {
public function findByUid(string $uid): Room {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('r.*')
->from($this->tableName, 'r')
->leftJoin('r', 'bbb_room_shares', 's', $qb->expr()->eq('r.id', 's.room_id'))
->addSelect($qb->createFunction('count(case when `s`.`permission` = 0 then 1 else null end) as shared'))
$this->joinShares($qb)
->where($qb->expr()->eq('r.uid', $qb->createNamedParameter($uid)))
->groupBy('r.id');
;
@ -70,25 +74,20 @@ class RoomMapper extends QBMapper {
public function findAll(string $userId, array $groupIds, array $circleIds): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('r.*')
->from($this->tableName, 'r')
->leftJoin('r', 'bbb_room_shares', 's', $qb->expr()->eq('r.id', 's.room_id'))
->addSelect($qb->createFunction('count(case when `s`.`permission` = 0 then 1 else null end) as shared'))
$this->joinShares($qb)
->addSelect($qb->createFunction('min(case when '.$qb->expr()->eq('r.user_id', $qb->createNamedParameter($userId)).' then '.RoomShare::PERMISSION_ADMIN.' else `s`.`permission` end) as permission'))
->where(
$qb->expr()->orX(
$qb->expr()->eq('r.user_id', $qb->createNamedParameter($userId)),
$qb->expr()->andX(
$qb->expr()->eq('s.permission', $qb->createNamedParameter(RoomShare::PERMISSION_ADMIN, IQueryBuilder::PARAM_INT)),
$qb->expr()->eq('s.share_type', $qb->createNamedParameter(RoomShare::SHARE_TYPE_USER, IQueryBuilder::PARAM_INT)),
$qb->expr()->eq('s.share_with', $qb->createNamedParameter($userId))
),
$qb->expr()->andX(
$qb->expr()->eq('s.permission', $qb->createNamedParameter(RoomShare::PERMISSION_ADMIN, IQueryBuilder::PARAM_INT)),
$qb->expr()->eq('s.share_type', $qb->createNamedParameter(RoomShare::SHARE_TYPE_GROUP, IQueryBuilder::PARAM_INT)),
$qb->expr()->in('s.share_with', $qb->createNamedParameter($groupIds, IQueryBuilder::PARAM_STR_ARRAY))
),
$qb->expr()->andX(
$qb->expr()->eq('s.permission', $qb->createNamedParameter(RoomShare::PERMISSION_ADMIN, IQueryBuilder::PARAM_INT)),
$qb->expr()->eq('s.share_type', $qb->createNamedParameter(RoomShare::SHARE_TYPE_CIRCLE, IQueryBuilder::PARAM_INT)),
$qb->expr()->in('s.share_with', $qb->createNamedParameter($circleIds, IQueryBuilder::PARAM_STR_ARRAY))
)

View File

@ -9,6 +9,7 @@ use OCA\BigBlueButton\Db\RoomShare;
use OCA\BigBlueButton\Service\RoomService;
use OCA\BigBlueButton\Service\RoomShareService;
use OCP\AppFramework\Http;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUserManager;
use PHPUnit\Framework\TestCase;
@ -19,6 +20,7 @@ class RoomShareControllerTest extends TestCase {
private $roomService;
private $circleHelper;
private $userManager;
private $groupManager;
private $controller;
private $userId = 'user_foo';
@ -29,6 +31,7 @@ class RoomShareControllerTest extends TestCase {
$this->request = $this->createMock(IRequest::class);
$this->service = $this->createMock(RoomShareService::class);
$this->userManager = $this->createMock(IUserManager::class);
$this->groupManager = $this->createMock(IGroupManager::class);
$this->roomService = $this->createMock(RoomService::class);
$this->circleHelper = $this->createMock(CircleHelper::class);
@ -37,6 +40,7 @@ class RoomShareControllerTest extends TestCase {
$this->request,
$this->service,
$this->userManager,
$this->groupManager,
$this->roomService,
$this->circleHelper,
$this->userId

View File

@ -39,6 +39,7 @@ export interface Room {
everyoneIsModerator: boolean;
requireModerator: boolean;
shared: boolean;
permission: Permission;
moderatorToken: string;
listenOnly: boolean,
mediaCheck: boolean,
@ -200,6 +201,14 @@ class Api {
return response.data;
}
public async publishRecording(id: string, publish: boolean,) {
const response = await axios.post(this.getUrl(`server/record/${id}/publish`), {
published: publish,
});
return response.data;
}
public async storeRecording(recording: Recording, path: string) {
const startDate = new Date(recording.startTime);
const filename = `${encodeURIComponent(recording.name + ' ' + startDate.toISOString())}.url`;

View File

@ -1,4 +1,4 @@
import { Access } from './Api';
import { Access, Permission } from './Api';
export const AccessOptions = {
[Access.Public]: t('bbb', 'Public'),
@ -8,3 +8,9 @@ export const AccessOptions = {
[Access.Internal]: t('bbb', 'Internal'),
[Access.InternalRestricted]: t('bbb', 'Internal restricted'),
};
export const PermissionsOptions = {
[Permission.Admin]: t('bbb', 'admin'),
[Permission.Moderator]: t('bbb', 'moderator'),
[Permission.User]: t('bbb', 'user'),
};

View File

@ -161,8 +161,23 @@ pre {
.bbb-shrink {
width: 44px;
white-space: nowrap;
}
input[type="checkbox"]{
&+label:before {
border-radius: 3px;
border-width: 2px;
}
&:disabled+label:before {
opacity: .5;
}
&:not(input:checked):disabled+label:before {
background-color: transparent !important;
}
}
}
th {
padding: 14px 6px;
@ -282,6 +297,10 @@ pre {
}
}
.bbb-simple-menu {
min-width: auto;
}
.bbb-input-container {
display: flex;
}

View File

@ -123,17 +123,18 @@ const EditRoomDialog: React.FC<Props> = ({ room, restriction, updateProperty, op
updateProperty('access', value);
})}
{room.access === Access.InternalRestricted && <div className="bbb-form-element bbb-form-shareWith">
<ShareWith permission={Permission.User} room={room} shares={shares} setShares={setShares} />
<em>{descriptions.internalRestrictedShareWith}</em>
</div>}
<div className="bbb-form-element">
<label htmlFor={'bbb-moderator'}>
<h3>Moderator</h3>
<label htmlFor={'bbb-sharing'}>
<h3>{t('bbb', 'Sharing')}</h3>
</label>
{!room.everyoneIsModerator && <ShareWith permission={Permission.Moderator} room={room} shares={shares} setShares={setShares} />}
{<ShareWith permission={Permission.User} room={room} shares={shares} setShares={setShares} />}
{room.access === Access.InternalRestricted &&
<div className="bbb-form-element bbb-form-shareWith">
<span className="icon icon-details icon-visible"></span><em>{t('bbb', 'Access') + ' : ' + descriptions.internalRestrictedShareWith}</em>
</div>
}
<div className="bbb-mt-1">
<input id={'bbb-everyoneIsModerator-' + room.id}

View File

@ -4,11 +4,29 @@ import { Recording } from '../Common/Api';
type Props = {
recording: Recording;
isAdmin : boolean;
deleteRecording: (recording: Recording) => void;
storeRecording: (recording: Recording) => void;
publishRecording: (recording: Recording, publish: boolean) => void;
}
const RecordingRow: React.FC<Props> = ({recording, deleteRecording, storeRecording}) => {
const RecordingRow: React.FC<Props> = ({recording, isAdmin, deleteRecording, storeRecording, publishRecording}) => {
function checkPublished(recording: Recording, onChange: (value: boolean) => void) {
return (
<div>
<input id={'bbb-record-state-' + recording.id}
type="checkbox"
className="checkbox"
checked={recording.state === 'published'}
onChange={(event) => onChange(event.target.checked)} />
<label htmlFor={'bbb-record-state-' + recording.id}>{t('bbb', 'Published')}</label>
</div>
);
}
return (
<tr key={recording.id}>
<td className="start icon-col">
@ -40,10 +58,17 @@ const RecordingRow: React.FC<Props> = ({recording, deleteRecording, storeRecordi
<td>
{recording.type}
</td>
<td>
{isAdmin && checkPublished(recording, (checked) => {
publishRecording(recording, checked);
})}
</td>
<td className="remove icon-col">
<button className="action-item" onClick={() => deleteRecording(recording)} title={t('bbb', 'Delete')}>
<span className="icon icon-delete icon-visible"></span>
</button>
{isAdmin &&
<button className="action-item" onClick={() => deleteRecording(recording)} title={t('bbb', 'Delete')}>
<span className="icon icon-delete icon-visible"></span>
</button>
}
</td>
</tr>
);

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { api, Recording, Room, Restriction, Access } from '../Common/Api';
import { api, Recording, Room, Restriction, Access, Permission } from '../Common/Api';
import EditRoom from './EditRoom';
import RecordingRow from './RecordingRow';
import EditableValue from './EditableValue';
@ -155,6 +155,28 @@ const RoomRow: React.FC<Props> = (props) => {
);
}
function publishRecording(recording: Recording, publish: boolean) {
api.publishRecording(recording.id, publish).then(success=> {
if (recordings === null) {
return;
}
setRecordings(recordings.map(recordItem => {
if (recordItem.id === recording.id) {
recordItem.published = success;
recordItem.state = success ? 'published' : 'unpublished';
}
return recordItem;
}));
}).catch(err => {
console.warn('Could not modify publishing state', err);
OC.dialogs.info(
t('bbb', 'Could not modify publishing state'),
t('bbb', 'Server error'),
() => undefined,
);
});
}
function accessToIcon(access: string) {
switch(access) {
case Access.Public:
@ -172,8 +194,11 @@ const RoomRow: React.FC<Props> = (props) => {
return <span></span>;
}
function edit(field: string, type: 'text' | 'number' = 'text', options?) {
return <EditableValue field={field} value={room[field]} setValue={updateRoom} type={type} options={options} />;
function edit(field: string, type: 'text' | 'number' = 'text', canEdit = true, options?) {
return canEdit ?
<EditableValue field={field} value={room[field]} setValue={updateRoom} type={type} options={options} />
:
<span>{room[field]}</span>;
}
function cloneRow() {
@ -189,6 +214,8 @@ const RoomRow: React.FC<Props> = (props) => {
const maxParticipantsLimit = props.restriction?.maxParticipants || -1;
const minParticipantsLimit = (props.restriction?.maxParticipants || -1) < 1 ? 0 : 1;
const adminRoom = room.permission === null || room.permission === Permission.Admin;
return (
<>
<tr className={showRecordings ? 'selected-row' : ''}>
@ -214,7 +241,7 @@ const RoomRow: React.FC<Props> = (props) => {
</button>
</td>
<td className="name">
{edit('name')}
{edit('name', 'text', adminRoom)}
</td>
<td className="bbb-shrink">
{room.userId !== OC.currentUser && <img src={avatarUrl} alt="Avatar" className="bbb-avatar" />}
@ -224,35 +251,43 @@ const RoomRow: React.FC<Props> = (props) => {
{accessToIcon(room.access)}
</td>
<td className="max-participants bbb-shrink">
{edit('maxParticipants', 'number', {min: minParticipantsLimit, max: maxParticipantsLimit < 0 ? undefined : maxParticipantsLimit})}
{edit('maxParticipants', 'number', adminRoom, {min: minParticipantsLimit, max: maxParticipantsLimit < 0 ? undefined : maxParticipantsLimit})}
</td>
<td className="record bbb-shrink">
<input id={'bbb-record-' + room.id} type="checkbox" className="checkbox" disabled={!props.restriction?.allowRecording} checked={room.record} onChange={(event) => updateRoom('record', event.target.checked)} />
<input id={'bbb-record-' + room.id} type="checkbox" className="checkbox" disabled={!adminRoom || !props.restriction?.allowRecording} checked={room.record} onChange={(event) => updateRoom('record', event.target.checked)} />
<label htmlFor={'bbb-record-' + room.id}></label>
</td>
<td className="bbb-shrink"><RecordingsNumber recordings={recordings} showRecordings={showRecordings} setShowRecordings={setShowRecordings} /></td>
<td className="edit icon-col">
<EditRoom room={props.room} restriction={props.restriction} updateProperty={updateRoom} />
<td className="bbb-shrink">
{<RecordingsNumber recordings={recordings} showRecordings={showRecordings} setShowRecordings={setShowRecordings} />}
</td>
<td className="clone icon-col">
{adminRoom &&
<button
className="action-item"
onClick={cloneRow}
title={t('bbb', 'Clone room')}>
<span className="icon icon-template-add icon-visible"></span>
</button>
}
</td>
<td className="edit icon-col">
{adminRoom &&
<EditRoom room={props.room} restriction={props.restriction} updateProperty={updateRoom} />
}
</td>
<td className="remove icon-col">
{adminRoom &&
<button className="action-item" onClick={deleteRow as any} title={t('bbb', 'Delete')}>
<span className="icon icon-delete icon-visible"></span>
</button>
}
</td>
</tr>
{showRecordings && <tr className="recordings-row">
<td colSpan={11}>
<table>
<tbody>
{recordings?.sort((r1, r2) => r1.startTime - r2.startTime).map(recording => <RecordingRow key={recording.id} recording={recording} deleteRecording={deleteRecording} storeRecording={storeRecording} />)}
{recordings?.sort((r1, r2) => r1.startTime - r2.startTime).map(recording => <RecordingRow key={recording.id} isAdmin={adminRoom} recording={recording} deleteRecording={deleteRecording} storeRecording={storeRecording} publishRecording={publishRecording} />)}
</tbody>
</table>
</td>

View File

@ -2,6 +2,7 @@ import React from 'react';
import { api, ShareWith, ShareType, RoomShare, Room, Permission } from '../Common/Api';
import './ShareWith.scss';
import ShareSelection from '../Common/ShareSelection';
import { PermissionsOptions } from '../Common/Translation';
type Props = {
room: Room;
@ -45,9 +46,7 @@ const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setSh
setShares((allShares ? [...allShares] : []).filter(share => share.id !== id));
}
async function toggleAdminShare(share: RoomShare) {
const newPermission = share.permission === Permission.Admin ? Permission.Moderator : Permission.Admin;
async function setSharePermission(share: RoomShare, newPermission: number) {
return addRoomShare(share.shareWith, share.shareType, share.shareWithDisplayName || share.shareWith, newPermission);
}
@ -59,17 +58,40 @@ const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setSh
});
}
function ucFirst(s: string)
{
return s && s[0].toUpperCase() + s.slice(1);
}
function selectPermission(value: Permission, onChange: (value: number) => void) {
return (
<div className="bbb-form-element bbb-simple-menu">
<select name="permission" value={value} onChange={(event) => onChange(Number(event.target.value))}>
{Object.keys(PermissionsOptions).map(key => {
const label = PermissionsOptions[key];
return <option key={key} value={key}>{ucFirst(label)}</option>;
})}
</select>
</div>
);
}
function permissionLabel(permission: Permission) {
return PermissionsOptions[permission] ?? '';
}
function renderShares(shares: RoomShare[]) {
const currentUser = OC.getCurrentUser();
const ROOM_OWNER_ID = -1;
const ownShare = {
id: -1,
id: ROOM_OWNER_ID,
roomId: room.id,
shareType: ShareType.User,
shareWith: currentUser.uid,
shareWithDisplayName: currentUser.displayName,
permission: Permission.Admin,
};
return (
<ul className="bbb-shareWith">
{[ownShare, ...shares].map(share => {
@ -85,20 +107,13 @@ const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setSh
</div>
<div className="bbb-shareWith__item__label">
<h5>{displayName}
{(share.permission === Permission.Moderator && permission === Permission.User) && (' (' + t('bbb', 'moderator') + ')')}
{(share.permission === Permission.Admin) && (' (' + t('bbb', 'admin') + ')')}</h5>
{(share.id === ROOM_OWNER_ID || !isOwner) && (' (' + permissionLabel(share.permission) + ')')}
</h5>
</div>
{(share.id > -1 && permission === Permission.Moderator && isOwner) && <div className="bbb-shareWith__item__action">
<button className="action-item"
onClick={ev => {
ev.preventDefault();
toggleAdminShare(share);
}}
title={t('bbb', 'Share')}>
<span className={'icon icon-shared icon-visible ' + (share.permission === Permission.Admin ? 'bbb-icon-selected' : 'bbb-icon-unselected')}></span>
</button>
</div>}
{(share.id > -1 && isOwner) && <div className="bbb-shareWith__item__action">
{(share.id > ROOM_OWNER_ID && isOwner) && selectPermission(share.permission, (value) => {
setSharePermission(share, value);
})}
{(share.id > ROOM_OWNER_ID && isOwner) && <div className="bbb-shareWith__item__action">
<button className="action-item"
onClick={ev => {ev.preventDefault(); deleteRoomShare(share.id);}}
title={t('bbb', 'Delete')}>
@ -116,8 +131,6 @@ const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setSh
return (
<>
{shares ? renderShares(shares) : loading}
{isOwner ?
<ShareSelection
selectShare={(shareOption) => addRoomShare(shareOption.value.shareWith, shareOption.value.shareType, shareOption.label, permission)}
@ -127,6 +140,8 @@ const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setSh
<span className="icon icon-details icon-visible"></span> {t('bbb', 'You are not allowed to change this option, because this room is shared with you.')}
</em>
}
{shares ? renderShares(shares) : loading}
</>
);
};