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

[MM-44651] Implement MaxCallParticipants config setting #93

Merged
merged 12 commits into from
Jun 7, 2022
7 changes: 7 additions & 0 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@
"help_text": "When set to true, calls can be started in all channels where they're not explicitly disabled.",
"default": false
},
{
"key": "MaxCallParticipants",
"display_name": "Max call participants",
"type": "number",
"help_text": "The maximum number of participants that can join a call. If left empty, or set to 0, it means unlimited.",
"default": 0
},
{
"key": "ICEHostOverride",
"display_name": "ICE Host Override",
Expand Down
10 changes: 0 additions & 10 deletions server/activate.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ package main
import (
"fmt"
"os"
"strconv"
"time"

"github.com/mattermost/mattermost-plugin-calls/server/enterprise"
Expand All @@ -24,15 +23,6 @@ func (p *Plugin) OnActivate() error {
return fmt.Errorf("disabled by environment flag")
}

maxPart := os.Getenv("MM_CALLS_CLOUD_MAX_PARTICIPANTS")
if maxPart != "" {
if max, err := strconv.Atoi(maxPart); err == nil {
cloudMaxParticipants = max
} else {
p.LogError("activate", "MM_CALLS_CLOUD_MAX_PARTICIPANTS error during parsing:", err.Error())
}
}

pluginAPIClient := pluginapi.NewClient(p.API, p.Driver)
p.pluginAPI = pluginAPIClient
p.licenseChecker = enterprise.NewLicenseChecker(pluginAPIClient)
Expand Down
8 changes: 3 additions & 5 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,13 +388,11 @@ func (p *Plugin) handleConfig(w http.ResponseWriter) error {

type config struct {
clientConfig
SkuShortName string `json:"sku_short_name"`
CloudMaxParticipants int `json:"cloud_max_participants"`
SkuShortName string `json:"sku_short_name"`
}
ret := config{
clientConfig: p.getConfiguration().getClientConfig(),
SkuShortName: skuShortName,
CloudMaxParticipants: cloudMaxParticipants,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize this is a breaking change but given it only affects Cloud I think we can safely make it now.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think... Good thing mobile isn't cut yet. :)

clientConfig: p.getConfiguration().getClientConfig(),
SkuShortName: skuShortName,
}

w.Header().Set("Content-Type", "application/json")
Expand Down
43 changes: 13 additions & 30 deletions server/cloud_limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,13 @@ import (
"net/http"
)

// cloudMaxParticipants defaults to 8, can be overridden by setting the env variable
// MM_CALLS_CLOUD_MAX_PARTICIPANTS
var cloudMaxParticipants = 8
// cloudMaxParticipantsDefault is set to 8.
// The value used can be overridden by setting the MM_CALLS_MAX_PARTICIPANTS env variable.

const maxAdminsToQueryForNotification = 25

// JoinAllowed returns true if the user is allowed to join the call, taking into
// account cloud limits
func (p *Plugin) joinAllowed(channel *model.Channel, state *channelState) (bool, error) {
// Rules are:
// On-prem: no limits to calls
// Cloud Starter: DMs 1-1 only
// Cloud Professional & Cloud Enterprise: DMs 1-1, GMs and Channel calls limited to 8 people.

license := p.pluginAPI.System.GetLicense()
if !isCloud(license) {
return true, nil
}

if isCloudStarter(license) {
return channel.Type == model.ChannelTypeDirect, nil
}

// we are cloud paid (starter or enterprise)
if len(state.Call.Users) >= cloudMaxParticipants {
return false, nil
}

return true, nil
}
const (
cloudMaxParticipantsDefault = 8
maxAdminsToQueryForNotification = 25
)

// handleCloudNotifyAdmins notifies the user's admin about upgrading for calls
func (p *Plugin) handleCloudNotifyAdmins(w http.ResponseWriter, r *http.Request) error {
Expand Down Expand Up @@ -71,12 +48,18 @@ func (p *Plugin) handleCloudNotifyAdmins(w http.ResponseWriter, r *http.Request)
return fmt.Errorf("no admins found")
}

maxParticipants := cloudMaxParticipantsDefault
cfg := p.getConfiguration()
if cfg != nil && cfg.MaxCallParticipants != nil {
maxParticipants = *cfg.MaxCallParticipants
}

separator := "\n\n---\n\n"
postType := "custom_cloud_trial_req"
message := fmt.Sprintf("@%s requested access to a free trial for Calls.", author.Username)
title := "Make calls in channels"
text := fmt.Sprintf("Start a call in a channel. You can include up to %d participants per call.%s[Upgrade now](https://customers.mattermost.com).",
cloudMaxParticipants, separator)
maxParticipants, separator)

attachments := []*model.SlackAttachment{
{
Expand Down
49 changes: 46 additions & 3 deletions server/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package main
import (
"errors"
"fmt"
"os"
"reflect"
"strconv"
"strings"
Expand Down Expand Up @@ -44,6 +45,9 @@ type clientConfig struct {
AllowEnableCalls *bool
// When set to true, calls will be possible in all channels where they are not explicitly disabled.
DefaultEnabled *bool
// The maximum number of participants that can join a call. The zero value
// means unlimited.
MaxCallParticipants *int
}

type ICEServers []string
Expand Down Expand Up @@ -107,9 +111,10 @@ func (pr PortsRange) IsValid() error {

func (c *configuration) getClientConfig() clientConfig {
return clientConfig{
AllowEnableCalls: c.AllowEnableCalls,
DefaultEnabled: c.DefaultEnabled,
ICEServers: c.ICEServers,
AllowEnableCalls: c.AllowEnableCalls,
DefaultEnabled: c.DefaultEnabled,
ICEServers: c.ICEServers,
MaxCallParticipants: c.MaxCallParticipants,
}
}

Expand All @@ -124,6 +129,9 @@ func (c *configuration) SetDefaults() {
c.DefaultEnabled = new(bool)
*c.DefaultEnabled = false
}
if c.MaxCallParticipants == nil {
c.MaxCallParticipants = new(int)
}
}

func (c *configuration) IsValid() error {
Expand All @@ -135,6 +143,10 @@ func (c *configuration) IsValid() error {
return fmt.Errorf("UDPServerPort is not valid: %d is not in allowed range [1024, 49151]", *c.UDPServerPort)
}

if c.MaxCallParticipants == nil || *c.MaxCallParticipants < 0 {
return fmt.Errorf("MaxCallParticipants is not valid")
}

return nil
}

Expand Down Expand Up @@ -165,6 +177,10 @@ func (c *configuration) Clone() *configuration {
}
}

if c.MaxCallParticipants != nil {
cfg.MaxCallParticipants = model.NewInt(*c.MaxCallParticipants)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question for myself: why model.NewInt here but new(int) everywhere else?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I feel like Hamlet now 💀

Part of me wants to avoid relying on model as much as possible but it's so tempting to save a line by using the util...

}

return &cfg
}

Expand Down Expand Up @@ -196,6 +212,10 @@ func (p *Plugin) setConfiguration(configuration *configuration) error {
p.configurationLock.Lock()
defer p.configurationLock.Unlock()

if p.configuration == nil && configuration != nil {
p.setOverrides(configuration)
}

if configuration != nil && p.configuration == configuration {
// Ignore assignment if the configuration struct is empty. Go will optimize the
// allocation for same to point at the same memory address, breaking the check
Expand Down Expand Up @@ -234,9 +254,32 @@ func (p *Plugin) OnConfigurationChange() error {
return fmt.Errorf("OnConfigurationChange: failed to load plugin configuration: %w", err)
}

// Permanently override with envVar and cloud overrides
p.setOverrides(cfg)

return p.setConfiguration(cfg)
}

func (p *Plugin) setOverrides(cfg *configuration) {
if license := p.API.GetLicense(); license != nil && isCloud(license) {
// On Cloud installations we want calls enabled in all channels so we
// override it since the plugin's default is now false.
*cfg.DefaultEnabled = true
}

// Allow env var to permanently override system console settings
if maxPart := os.Getenv("MM_CALLS_MAX_PARTICIPANTS"); maxPart != "" {
if max, err := strconv.Atoi(maxPart); err == nil {
*cfg.MaxCallParticipants = max
} else {
p.LogError("setOverrides", "failed to parse MM_CALLS_MAX_PARTICIPANTS", err.Error())
}
} else if license := p.API.GetLicense(); license != nil && isCloud(license) {
// otherwise, if this is a cloud installation, set it at the default
*cfg.MaxCallParticipants = cloudMaxParticipantsDefault
}
}

func (p *Plugin) isHAEnabled() bool {
cfg := p.API.GetConfig()
return cfg != nil && cfg.ClusterSettings.Enable != nil && *cfg.ClusterSettings.Enable
Expand Down
29 changes: 27 additions & 2 deletions server/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ func (p *Plugin) addUserSession(userID, connID string, channel *model.Channel) (
// Check for cloud limits -- needs to be done here to prevent a race condition
if allowed, err := p.joinAllowed(channel, state); !allowed {
if err != nil {
p.LogError("error checking for cloud limits", "error", err.Error())
p.LogError("joinAllowed failed", "error", err.Error())
}
return nil, fmt.Errorf("user cannot join because of cloud limits")
return nil, fmt.Errorf("user cannot join because of limits")
}

state.Call.Users[userID] = &userState{}
Expand Down Expand Up @@ -169,3 +169,28 @@ func (p *Plugin) removeUserSession(userID, connID, channelID string) (channelSta

return currState, prevState, err
}

// JoinAllowed returns true if the user is allowed to join the call, taking into
// account cloud and configuration limits
func (p *Plugin) joinAllowed(channel *model.Channel, state *channelState) (bool, error) {
// Rules are:
// On-prem, Cloud Professional & Cloud Enterprise: DMs 1-1, GMs and Channel calls
// limited to cfg.MaxCallParticipants people.
// Cloud Starter: DMs 1-1 only

if cfg := p.getConfiguration(); cfg != nil && cfg.MaxCallParticipants != nil &&
*cfg.MaxCallParticipants != 0 && len(state.Call.Users) >= *cfg.MaxCallParticipants {
return false, nil
}

license := p.pluginAPI.System.GetLicense()
if !isCloud(license) {
return true, nil
}

if isCloudStarter(license) {
return channel.Type == model.ChannelTypeDirect, nil
}

return true, nil
}
4 changes: 2 additions & 2 deletions webapp/src/components/channel_call_toast/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface Props {
startAt?: number,
pictures: string[],
profiles: UserProfile[],
isCloudLimitRestricted: boolean,
isLimitRestricted: boolean,
}

interface State {
Expand Down Expand Up @@ -58,7 +58,7 @@ export default class ChannelCallToast extends React.PureComponent<Props, State>
}

render() {
if (!this.props.hasCall || this.state.hidden || this.props.isCloudLimitRestricted) {
if (!this.props.hasCall || this.state.hidden || this.props.isLimitRestricted) {
return null;
}

Expand Down
4 changes: 2 additions & 2 deletions webapp/src/components/channel_call_toast/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
voiceConnectedProfilesInChannel,
connectedChannelID,
voiceChannelCallStartAt,
isCloudLimitRestricted,
isLimitRestricted,
} from 'selectors';

import ChannelCallToast from './component';
Expand Down Expand Up @@ -39,7 +39,7 @@ const mapStateToProps = (state) => {
startAt: voiceChannelCallStartAt(state, currentID),
pictures,
profiles,
isCloudLimitRestricted: isCloudLimitRestricted(state),
isLimitRestricted: isLimitRestricted(state),
};
};

Expand Down
25 changes: 16 additions & 9 deletions webapp/src/components/channel_header_button/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,32 @@ interface Props {
inCall: boolean,
hasCall: boolean,
isCloudFeatureRestricted: boolean,
isCloudLimitRestricted: boolean,
cloudMaxParticipants: number,
isCloudPaid: boolean,
isLimitRestricted: boolean,
maxParticipants: number,
}

const ChannelHeaderButton = ({
show,
inCall,
hasCall,
isCloudFeatureRestricted,
isCloudLimitRestricted,
cloudMaxParticipants,
isCloudPaid,
isLimitRestricted,
maxParticipants,
}: Props) => {
if (!show) {
return null;
}
const restricted = isCloudFeatureRestricted || isCloudLimitRestricted;

const restricted = isLimitRestricted || isCloudFeatureRestricted;

const button = (
<CallButton
id='calls-join-button'
className={'style--none call-button ' + (inCall || restricted ? 'disabled' : '')}
restricted={restricted}
isCloudPaid={isCloudPaid}
>
<CompassIcon icon='phone-outline'/>
<span className='call-button-label'>
Expand Down Expand Up @@ -65,18 +69,21 @@ const ChannelHeaderButton = ({
);
}

if (isCloudLimitRestricted && !inCall) {
if (isLimitRestricted && !inCall) {
return (
<OverlayTrigger
placement='bottom'
overlay={
<Tooltip id='tooltip-limit-header'>
<Header>
{`There's a limit of ${cloudMaxParticipants} participants per call.`}
{`There's a limit of ${maxParticipants} participants per call.`}
</Header>

{isCloudPaid &&
<SubHeader>
{'This is because calls is currently in beta. We’re working to remove this limit soon.'}
</SubHeader>
}
</Tooltip>
}
>
Expand All @@ -88,15 +95,15 @@ const ChannelHeaderButton = ({
return button;
};

const CallButton = styled.button<{ restricted: boolean }>`
const CallButton = styled.button<{ restricted: boolean, isCloudPaid: boolean }>`
// &&& is to override the call-button styles
&&& {
${(props) => props.restricted && css`
color: rgba(var(--center-channel-color-rgb), 0.48);
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
cursor: pointer;
margin-right: 4px;
`}
cursor: ${(props) => (props.restricted && props.isCloudPaid ? 'not-allowed' : 'pointer')};
}
`;

Expand Down
Loading