From b08393295fe420738bcd2f61ab704ad33b64575e Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sun, 26 Jun 2022 00:46:55 -0700 Subject: [PATCH] Add option to hide viewer count. Closes #1939 --- controllers/admin/config.go | 20 ++ controllers/admin/serverConfig.go | 2 + controllers/status.go | 8 +- core/data/config.go | 12 + router/router.go | 3 + test/automated/api/configmanagement.test.js | 261 ++++++++++-------- .../config/edit-instance-details.tsx | 13 + web/components/ui/Statusbar/Statusbar.tsx | 4 +- web/types/config-section.ts | 1 + web/utils/config-constants.tsx | 8 + web/utils/server-status-context.tsx | 2 + 11 files changed, 209 insertions(+), 125 deletions(-) diff --git a/controllers/admin/config.go b/controllers/admin/config.go index 082f052da..df1c23785 100644 --- a/controllers/admin/config.go +++ b/controllers/admin/config.go @@ -711,6 +711,26 @@ func SetChatJoinMessagesEnabled(w http.ResponseWriter, r *http.Request) { controllers.WriteSimpleResponse(w, true, "chat join message status updated") } +// SetHideViewerCount will enable or disable hiding the viewer count. +func SetHideViewerCount(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + controllers.WriteSimpleResponse(w, false, "unable to update hiding viewer count") + return + } + + if err := data.SetHideViewerCount(configValue.Value.(bool)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "hide viewer count setting updated") +} + func requirePOST(w http.ResponseWriter, r *http.Request) bool { if r.Method != controllers.POST { controllers.WriteSimpleResponse(w, false, r.Method+" not supported") diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go index 91b1c5be9..fceb9d9f4 100644 --- a/controllers/admin/serverConfig.go +++ b/controllers/admin/serverConfig.go @@ -55,6 +55,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { ChatJoinMessagesEnabled: data.GetChatJoinMessagesEnabled(), SocketHostOverride: data.GetWebsocketOverrideHost(), ChatEstablishedUserMode: data.GetChatEstbalishedUsersOnlyMode(), + HideViewerCount: data.GetHideViewerCount(), VideoSettings: videoSettings{ VideoQualityVariants: videoQualityVariants, LatencyLevel: data.GetStreamLatencyLevel().Level, @@ -113,6 +114,7 @@ type serverConfigAdminResponse struct { SuggestedUsernames []string `json:"suggestedUsernames"` SocketHostOverride string `json:"socketHostOverride,omitempty"` Notifications notificationsConfigResponse `json:"notifications"` + HideViewerCount bool `json:"hideViewerCount"` } type videoSettings struct { diff --git a/controllers/status.go b/controllers/status.go index 30f3d203a..4c316bbf0 100644 --- a/controllers/status.go +++ b/controllers/status.go @@ -6,6 +6,7 @@ import ( "time" "github.com/owncast/owncast/core" + "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/router/middleware" "github.com/owncast/owncast/utils" ) @@ -15,7 +16,6 @@ func GetStatus(w http.ResponseWriter, r *http.Request) { status := core.GetStatus() response := webStatusResponse{ Online: status.Online, - ViewerCount: status.ViewerCount, ServerTime: time.Now(), LastConnectTime: status.LastConnectTime, LastDisconnectTime: status.LastDisconnectTime, @@ -23,6 +23,10 @@ func GetStatus(w http.ResponseWriter, r *http.Request) { StreamTitle: status.StreamTitle, } + if !data.GetHideViewerCount() { + response.ViewerCount = status.ViewerCount + } + w.Header().Set("Content-Type", "application/json") middleware.DisableCache(w) @@ -33,7 +37,7 @@ func GetStatus(w http.ResponseWriter, r *http.Request) { type webStatusResponse struct { Online bool `json:"online"` - ViewerCount int `json:"viewerCount"` + ViewerCount int `json:"viewerCount,omitempty"` ServerTime time.Time `json:"serverTime"` LastConnectTime *utils.NullTime `json:"lastConnectTime"` LastDisconnectTime *utils.NullTime `json:"lastDisconnectTime"` diff --git a/core/data/config.go b/core/data/config.go index 3202bc030..fc55bc027 100644 --- a/core/data/config.go +++ b/core/data/config.go @@ -65,6 +65,7 @@ const ( browserPushPrivateKeyKey = "browser_push_private_key" twitterConfigurationKey = "twitter_configuration" hasConfiguredInitialNotificationsKey = "has_configured_initial_notifications" + hideViewerCountKey = "hide_viewer_count" ) // GetExtraPageBodyContent will return the user-supplied body content. @@ -908,3 +909,14 @@ func GetHasPerformedInitialNotificationsConfig() bool { configured, _ := _datastore.GetBool(hasConfiguredInitialNotificationsKey) return configured } + +// GetHideViewerCount will return if the viewer count shold be hidden. +func GetHideViewerCount() bool { + hide, _ := _datastore.GetBool(hideViewerCountKey) + return hide +} + +// SetHideViewerCount will set if the viewer count should be hidden. +func SetHideViewerCount(hide bool) error { + return _datastore.SetBool(hideViewerCountKey, hide) +} diff --git a/router/router.go b/router/router.go index 20a5b157f..c3faa329b 100644 --- a/router/router.go +++ b/router/router.go @@ -312,6 +312,9 @@ func Start() error { // Video playback metrics http.HandleFunc("/api/admin/metrics/video", middleware.RequireAdminAuth(admin.GetVideoPlaybackMetrics)) + // Is the viewer count hidden from viewers + http.HandleFunc("/api/admin/config/hideviewercount", middleware.RequireAdminAuth(admin.SetHideViewerCount)) + // Inline chat moderation actions // Update chat message visibility diff --git a/test/automated/api/configmanagement.test.js b/test/automated/api/configmanagement.test.js index 22a3292e8..155693131 100644 --- a/test/automated/api/configmanagement.test.js +++ b/test/automated/api/configmanagement.test.js @@ -9,192 +9,211 @@ const tags = [randomString(), randomString(), randomString()]; const latencyLevel = Math.floor(Math.random() * 4); const streamOutputVariants = { - videoBitrate: randomNumber() * 100, - framerate: 42, - cpuUsageLevel: 2, - scaledHeight: randomNumber() * 100, - scaledWidth: randomNumber() * 100, + videoBitrate: randomNumber() * 100, + framerate: 42, + cpuUsageLevel: 2, + scaledHeight: randomNumber() * 100, + scaledWidth: randomNumber() * 100, }; const socialHandles = [ - { - url: 'http://facebook.org/' + randomString(), - platform: randomString(), - }, + { + url: 'http://facebook.org/' + randomString(), + platform: randomString(), + }, ]; const s3Config = { - enabled: true, - endpoint: 'http://' + randomString(), - accessKey: randomString(), - secret: randomString(), - bucket: randomString(), - region: randomString(), - forcePathStyle: true, + enabled: true, + endpoint: 'http://' + randomString(), + accessKey: randomString(), + secret: randomString(), + bucket: randomString(), + region: randomString(), + forcePathStyle: true, }; const forbiddenUsernames = [randomString(), randomString(), randomString()]; test('set server name', async (done) => { - const res = await sendConfigChangeRequest('name', serverName); - done(); + const res = await sendConfigChangeRequest('name', serverName); + done(); }); test('set stream title', async (done) => { - const res = await sendConfigChangeRequest('streamtitle', streamTitle); - done(); + const res = await sendConfigChangeRequest('streamtitle', streamTitle); + done(); }); test('set server summary', async (done) => { - const res = await sendConfigChangeRequest('serversummary', serverSummary); - done(); + const res = await sendConfigChangeRequest('serversummary', serverSummary); + done(); }); test('set extra page content', async (done) => { - const res = await sendConfigChangeRequest('pagecontent', pageContent); - done(); + const res = await sendConfigChangeRequest('pagecontent', pageContent); + done(); }); test('set tags', async (done) => { - const res = await sendConfigChangeRequest('tags', tags); - done(); + const res = await sendConfigChangeRequest('tags', tags); + done(); }); test('set latency level', async (done) => { - const res = await sendConfigChangeRequest( - 'video/streamlatencylevel', - latencyLevel - ); - done(); + const res = await sendConfigChangeRequest( + 'video/streamlatencylevel', + latencyLevel + ); + done(); }); test('set video stream output variants', async (done) => { - const res = await sendConfigChangeRequest('video/streamoutputvariants', [ - streamOutputVariants, - ]); - done(); + const res = await sendConfigChangeRequest('video/streamoutputvariants', [ + streamOutputVariants, + ]); + done(); }); test('set social handles', async (done) => { - const res = await sendConfigChangeRequest('socialhandles', socialHandles); - done(); + const res = await sendConfigChangeRequest('socialhandles', socialHandles); + done(); }); test('set s3 configuration', async (done) => { - const res = await sendConfigChangeRequest('s3', s3Config); - done(); + const res = await sendConfigChangeRequest('s3', s3Config); + done(); }); test('set forbidden usernames', async (done) => { - const res = await sendConfigChangeRequest('chat/forbiddenusernames', forbiddenUsernames); - done(); + const res = await sendConfigChangeRequest( + 'chat/forbiddenusernames', + forbiddenUsernames + ); + done(); +}); + +test('set hide viewer count', async (done) => { + const res = await sendConfigChangeRequest('hideviewercount', true); + done(); }); test('verify updated config values', async (done) => { - const res = await request.get('/api/config'); - expect(res.body.name).toBe(serverName); - expect(res.body.streamTitle).toBe(streamTitle); - expect(res.body.summary).toBe(`

${serverSummary}

`); - expect(res.body.extraPageContent).toBe(pageContent); - expect(res.body.logo).toBe('/logo'); - expect(res.body.socialHandles).toStrictEqual(socialHandles); - done(); + const res = await request.get('/api/config'); + expect(res.body.name).toBe(serverName); + expect(res.body.streamTitle).toBe(streamTitle); + expect(res.body.summary).toBe(`

${serverSummary}

`); + expect(res.body.extraPageContent).toBe(pageContent); + expect(res.body.logo).toBe('/logo'); + expect(res.body.socialHandles).toStrictEqual(socialHandles); + done(); }); // Test that the raw video details being broadcasted are coming through -test('stream details are correct', (done) => { - request - .get('/api/admin/status') - .auth('admin', 'abc123') - .expect(200) - .then((res) => { - expect(res.body.broadcaster.streamDetails.width).toBe(320); - expect(res.body.broadcaster.streamDetails.height).toBe(180); - expect(res.body.broadcaster.streamDetails.framerate).toBe(24); - expect(res.body.broadcaster.streamDetails.videoBitrate).toBe(1269); - expect(res.body.broadcaster.streamDetails.videoCodec).toBe('H.264'); - expect(res.body.broadcaster.streamDetails.audioCodec).toBe('AAC'); - expect(res.body.online).toBe(true); - done(); - }); +test('admin stream details are correct', (done) => { + request + .get('/api/admin/status') + .auth('admin', 'abc123') + .expect(200) + .then((res) => { + expect(res.body.broadcaster.streamDetails.width).toBe(320); + expect(res.body.broadcaster.streamDetails.height).toBe(180); + expect(res.body.broadcaster.streamDetails.framerate).toBe(24); + expect(res.body.broadcaster.streamDetails.videoBitrate).toBe(1269); + expect(res.body.broadcaster.streamDetails.videoCodec).toBe('H.264'); + expect(res.body.broadcaster.streamDetails.audioCodec).toBe('AAC'); + expect(res.body.online).toBe(true); + done(); + }); }); test('admin configuration is correct', (done) => { - request - .get('/api/admin/serverconfig') - .auth('admin', 'abc123') - .expect(200) - .then((res) => { - expect(res.body.instanceDetails.name).toBe(serverName); - expect(res.body.instanceDetails.summary).toBe(serverSummary); - expect(res.body.instanceDetails.tags).toStrictEqual(tags); - expect(res.body.instanceDetails.socialHandles).toStrictEqual( - socialHandles - ); - expect(res.body.forbiddenUsernames).toStrictEqual(forbiddenUsernames); + request + .get('/api/admin/serverconfig') + .auth('admin', 'abc123') + .expect(200) + .then((res) => { + expect(res.body.instanceDetails.name).toBe(serverName); + expect(res.body.instanceDetails.summary).toBe(serverSummary); + expect(res.body.instanceDetails.tags).toStrictEqual(tags); + expect(res.body.instanceDetails.socialHandles).toStrictEqual( + socialHandles + ); + expect(res.body.forbiddenUsernames).toStrictEqual(forbiddenUsernames); - expect(res.body.videoSettings.latencyLevel).toBe(latencyLevel); - expect(res.body.videoSettings.videoQualityVariants[0].framerate).toBe( - streamOutputVariants.framerate - ); - expect(res.body.videoSettings.videoQualityVariants[0].cpuUsageLevel).toBe( - streamOutputVariants.cpuUsageLevel - ); + expect(res.body.videoSettings.latencyLevel).toBe(latencyLevel); + expect(res.body.videoSettings.videoQualityVariants[0].framerate).toBe( + streamOutputVariants.framerate + ); + expect(res.body.videoSettings.videoQualityVariants[0].cpuUsageLevel).toBe( + streamOutputVariants.cpuUsageLevel + ); - expect(res.body.yp.enabled).toBe(false); - expect(res.body.streamKey).toBe('abc123'); + expect(res.body.yp.enabled).toBe(false); + expect(res.body.streamKey).toBe('abc123'); - expect(res.body.s3.enabled).toBe(s3Config.enabled); - expect(res.body.s3.endpoint).toBe(s3Config.endpoint); - expect(res.body.s3.accessKey).toBe(s3Config.accessKey); - expect(res.body.s3.secret).toBe(s3Config.secret); - expect(res.body.s3.bucket).toBe(s3Config.bucket); - expect(res.body.s3.region).toBe(s3Config.region); - expect(res.body.s3.forcePathStyle).toBeTruthy(); - done(); - }); + expect(res.body.s3.enabled).toBe(s3Config.enabled); + expect(res.body.s3.endpoint).toBe(s3Config.endpoint); + expect(res.body.s3.accessKey).toBe(s3Config.accessKey); + expect(res.body.s3.secret).toBe(s3Config.secret); + expect(res.body.s3.bucket).toBe(s3Config.bucket); + expect(res.body.s3.region).toBe(s3Config.region); + expect(res.body.s3.forcePathStyle).toBe(true); + expect(res.body.hideViewerCount).toBe(true); + done(); + }); }); test('frontend configuration is correct', (done) => { - request - .get('/api/config') - .expect(200) - .then((res) => { - expect(res.body.name).toBe(serverName); - expect(res.body.logo).toBe('/logo'); - expect(res.body.socialHandles).toStrictEqual(socialHandles); - done(); - }); + request + .get('/api/config') + .expect(200) + .then((res) => { + expect(res.body.name).toBe(serverName); + expect(res.body.logo).toBe('/logo'); + expect(res.body.socialHandles).toStrictEqual(socialHandles); + done(); + }); +}); + +test('frontend status is correct', (done) => { + request + .get('/api/status') + .expect(200) + .then((res) => { + expect(res.body.viewerCount).toBe(undefined); + done(); + }); }); async function sendConfigChangeRequest(endpoint, value) { - const url = '/api/admin/config/' + endpoint; - const res = await request - .post(url) - .auth('admin', 'abc123') - .send({ value: value }) - .expect(200); + const url = '/api/admin/config/' + endpoint; + const res = await request + .post(url) + .auth('admin', 'abc123') + .send({ value: value }) + .expect(200); - expect(res.body.success).toBe(true); - return res; + expect(res.body.success).toBe(true); + return res; } async function sendConfigChangePayload(endpoint, payload) { - const url = '/api/admin/config/' + endpoint; - const res = await request - .post(url) - .auth('admin', 'abc123') - .send(payload) - .expect(200); + const url = '/api/admin/config/' + endpoint; + const res = await request + .post(url) + .auth('admin', 'abc123') + .send(payload) + .expect(200); - expect(res.body.success).toBe(true); + expect(res.body.success).toBe(true); - return res; + return res; } function randomString(length = 20) { - return Math.random().toString(16).substr(2, length); + return Math.random().toString(16).substr(2, length); } function randomNumber() { - return Math.floor(Math.random() * 5); + return Math.floor(Math.random() * 5); } diff --git a/web/components/config/edit-instance-details.tsx b/web/components/config/edit-instance-details.tsx index ed39c52fd..8dfe9dade 100644 --- a/web/components/config/edit-instance-details.tsx +++ b/web/components/config/edit-instance-details.tsx @@ -15,6 +15,7 @@ import { API_YP_SWITCH, FIELD_PROPS_YP, FIELD_PROPS_NSFW, + FIELD_PROPS_HIDE_VIEWER_COUNT, } from '../../utils/config-constants'; import { UpdateArgs } from '../../types/config-section'; @@ -61,6 +62,10 @@ export default function EditInstanceDetails() { }); }; + function handleHideViewerCountChange(enabled: boolean) { + handleFieldChange({ fieldName: 'hideViewerCount', value: enabled }); + } + const hasInstanceUrl = instanceUrl !== ''; return ( @@ -100,6 +105,14 @@ export default function EditInstanceDetails() { {/* Logo section */} + +

Increase your audience by appearing in the{' '} diff --git a/web/components/ui/Statusbar/Statusbar.tsx b/web/components/ui/Statusbar/Statusbar.tsx index 41f093545..a3e5103f6 100644 --- a/web/components/ui/Statusbar/Statusbar.tsx +++ b/web/components/ui/Statusbar/Statusbar.tsx @@ -40,12 +40,12 @@ export default function Statusbar(props: Props) { if (online && lastConnectTime) { const duration = makeDurationString(new Date(lastConnectTime)); onlineMessage = online ? `Live for ${duration}` : 'Offline'; - rightSideMessage = ( + rightSideMessage = viewerCount > 0 && ( {viewerCount} ); - } else { + } else if (!online) { onlineMessage = 'Offline'; if (lastDisconnectTime) { rightSideMessage = `Last live ${formatDistanceToNow(new Date(lastDisconnectTime))} ago.`; diff --git a/web/types/config-section.ts b/web/types/config-section.ts index 41cc84437..9879beccb 100644 --- a/web/types/config-section.ts +++ b/web/types/config-section.ts @@ -152,4 +152,5 @@ export interface ConfigDetails { notifications: NotificationsConfig; chatJoinMessagesEnabled: boolean; chatEstablishedUserMode: boolean; + hideViewerCount: boolean; } diff --git a/web/utils/config-constants.tsx b/web/utils/config-constants.tsx index 3d653f736..52bcd20c5 100644 --- a/web/utils/config-constants.tsx +++ b/web/utils/config-constants.tsx @@ -29,6 +29,7 @@ export const API_VIDEO_SEGMENTS = '/video/streamlatencylevel'; export const API_VIDEO_VARIANTS = '/video/streamoutputvariants'; export const API_WEB_PORT = '/webserverport'; export const API_YP_SWITCH = '/directoryenabled'; +export const API_HIDE_VIEWER_COUNT = '/hideviewercount'; export const API_CHAT_DISABLE = '/chat/disable'; export const API_CHAT_JOIN_MESSAGES_ENABLED = '/chat/joinmessagesenabled'; export const API_CHAT_ESTABLISHED_MODE = '/chat/establishedusermode'; @@ -188,6 +189,13 @@ export const FIELD_PROPS_YP = { tip: 'Turn this ON to request to show up in the directory.', }; +export const FIELD_PROPS_HIDE_VIEWER_COUNT = { + apiPath: API_HIDE_VIEWER_COUNT, + configPath: '', + label: 'Hide viewer count', + tip: 'Turn this ON to hide the viewer count the web page.', +}; + export const DEFAULT_VARIANT_STATE: VideoVariant = { framerate: 24, videoPassthrough: false, diff --git a/web/utils/server-status-context.tsx b/web/utils/server-status-context.tsx index 92fb4edcf..9ae1a06b3 100644 --- a/web/utils/server-status-context.tsx +++ b/web/utils/server-status-context.tsx @@ -75,6 +75,7 @@ export const initialServerConfigState: ConfigDetails = { chatDisabled: false, chatJoinMessagesEnabled: true, chatEstablishedUserMode: false, + hideViewerCount: false, }; const initialServerStatusState = { @@ -156,6 +157,7 @@ const ServerStatusProvider = ({ children }) => { }; }, []); + // eslint-disable-next-line react/jsx-no-constructed-context-values const providerValue = { ...status, serverConfig: config,