0

Gek/external actions (#827)

* WIP External actions modal frontend

* Add external action links

* Allow modal to show/hide and use a dynamic url

* Use external link object instead of just url for state

* add style and placement to external action buttons

* reformat and simplify tag list style as not to conflict with action buttons and make them look less actionable since they're not

* fix bug to open modal

* have Esc key close modal

* fix style on modal

* make modal bg darker

* close modal when you click outside of it

* fix zindex

* Add support for external action icons and colors

* Some external action modal sizing + loading spinner

Co-authored-by: Ginger Wong <omqmail@gmail.com>
This commit is contained in:
Gabe Kangas 2021-03-15 15:32:52 -07:00 committed by GitHub
parent 84f74f0353
commit 3fb80554ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 288 additions and 37 deletions

View File

@ -452,6 +452,26 @@ func SetChatDisabled(w http.ResponseWriter, r *http.Request) {
controllers.WriteSimpleResponse(w, true, "chat disabled status updated") controllers.WriteSimpleResponse(w, true, "chat disabled status updated")
} }
// SetExternalActions will set the 3rd party actions for the web interface.
func SetExternalActions(w http.ResponseWriter, r *http.Request) {
type externalActionsRequest struct {
Value []models.ExternalAction `json:"value"`
}
decoder := json.NewDecoder(r.Body)
var actions externalActionsRequest
if err := decoder.Decode(&actions); err != nil {
controllers.WriteSimpleResponse(w, false, "unable to update external actions with provided values")
return
}
if err := data.SetExternalActions(actions.Value); err != nil {
controllers.WriteSimpleResponse(w, false, "unable to update external actions with provided values")
}
controllers.WriteSimpleResponse(w, true, "external actions update")
}
func requirePOST(w http.ResponseWriter, r *http.Request) bool { func requirePOST(w http.ResponseWriter, r *http.Request) bool {
if r.Method != controllers.POST { if r.Method != controllers.POST {
controllers.WriteSimpleResponse(w, false, r.Method+" not supported") controllers.WriteSimpleResponse(w, false, r.Method+" not supported")

View File

@ -52,7 +52,8 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
Enabled: data.GetDirectoryEnabled(), Enabled: data.GetDirectoryEnabled(),
InstanceURL: data.GetServerURL(), InstanceURL: data.GetServerURL(),
}, },
S3: data.GetS3Config(), S3: data.GetS3Config(),
ExternalActions: data.GetExternalActions(),
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -63,16 +64,17 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
} }
type serverConfigAdminResponse struct { type serverConfigAdminResponse struct {
InstanceDetails webConfigResponse `json:"instanceDetails"` InstanceDetails webConfigResponse `json:"instanceDetails"`
FFmpegPath string `json:"ffmpegPath"` FFmpegPath string `json:"ffmpegPath"`
StreamKey string `json:"streamKey"` StreamKey string `json:"streamKey"`
WebServerPort int `json:"webServerPort"` WebServerPort int `json:"webServerPort"`
RTMPServerPort int `json:"rtmpServerPort"` RTMPServerPort int `json:"rtmpServerPort"`
S3 models.S3 `json:"s3"` S3 models.S3 `json:"s3"`
VideoSettings videoSettings `json:"videoSettings"` VideoSettings videoSettings `json:"videoSettings"`
LatencyLevel int `json:"latencyLevel"` LatencyLevel int `json:"latencyLevel"`
YP yp `json:"yp"` YP yp `json:"yp"`
ChatDisabled bool `json:"chatDisabled"` ChatDisabled bool `json:"chatDisabled"`
ExternalActions []models.ExternalAction `json:"externalActions"`
} }
type videoSettings struct { type videoSettings struct {

View File

@ -12,16 +12,17 @@ import (
) )
type webConfigResponse struct { type webConfigResponse struct {
Name string `json:"name"` Name string `json:"name"`
Summary string `json:"summary"` Summary string `json:"summary"`
Logo string `json:"logo"` Logo string `json:"logo"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Version string `json:"version"` Version string `json:"version"`
NSFW bool `json:"nsfw"` NSFW bool `json:"nsfw"`
ExtraPageContent string `json:"extraPageContent"` ExtraPageContent string `json:"extraPageContent"`
StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream
SocialHandles []models.SocialHandle `json:"socialHandles"` SocialHandles []models.SocialHandle `json:"socialHandles"`
ChatDisabled bool `json:"chatDisabled"` ChatDisabled bool `json:"chatDisabled"`
ExternalActions []models.ExternalAction `json:"externalActions"`
} }
// GetWebConfig gets the status of the server. // GetWebConfig gets the status of the server.
@ -50,6 +51,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
StreamTitle: data.GetStreamTitle(), StreamTitle: data.GetStreamTitle(),
SocialHandles: socialHandles, SocialHandles: socialHandles,
ChatDisabled: data.GetChatDisabled(), ChatDisabled: data.GetChatDisabled(),
ExternalActions: data.GetExternalActions(),
} }
if err := json.NewEncoder(w).Encode(configuration); err != nil { if err := json.NewEncoder(w).Encode(configuration); err != nil {

View File

@ -34,6 +34,7 @@ const s3StorageConfigKey = "s3_storage_config"
const videoLatencyLevel = "video_latency_level" const videoLatencyLevel = "video_latency_level"
const videoStreamOutputVariantsKey = "video_stream_output_variants" const videoStreamOutputVariantsKey = "video_stream_output_variants"
const chatDisabledKey = "chat_disabled" const chatDisabledKey = "chat_disabled"
const externalActionsKey = "external_actions"
// GetExtraPageBodyContent will return the user-supplied body content. // GetExtraPageBodyContent will return the user-supplied body content.
func GetExtraPageBodyContent() string { func GetExtraPageBodyContent() string {
@ -424,6 +425,27 @@ func GetChatDisabled() bool {
return false return false
} }
// GetExternalActions will return the registered external actions.
func GetExternalActions() []models.ExternalAction {
configEntry, err := _datastore.Get(externalActionsKey)
if err != nil {
return []models.ExternalAction{}
}
var externalActions []models.ExternalAction
if err := configEntry.getObject(&externalActions); err != nil {
return []models.ExternalAction{}
}
return externalActions
}
// SetExternalActions will save external actions.
func SetExternalActions(actions []models.ExternalAction) error {
var configEntry = ConfigEntry{Key: externalActionsKey, Value: actions}
return _datastore.Save(configEntry)
}
// VerifySettings will perform a sanity check for specific settings values. // VerifySettings will perform a sanity check for specific settings values.
func VerifySettings() error { func VerifySettings() error {
if GetStreamKey() == "" { if GetStreamKey() == "" {

17
models/externalAction.go Normal file
View File

@ -0,0 +1,17 @@
package models
// ExternalAction is a link that will open as a 3rd party action.
type ExternalAction struct {
// URL is the URL to load.
URL string `json:"url"`
// Title is the name of this action, displayed in the modal.
Title string `json:"title"`
// Description is the description of this action.
Description string `json:"description"`
// Icon is the optional icon for the button associated with this action.
Icon string `json:"icon"`
// Color is the optional color for the button associated with this action.
Color string `json:"color"`
// OpenExternally states if the action should open a new tab/window instead of an internal modal.
OpenExternally bool `json:"openExternally"`
}

View File

@ -150,6 +150,7 @@ func Start() error {
// Connected clients // Connected clients
http.HandleFunc("/api/integrations/clients", middleware.RequireAccessToken(models.ScopeHasAdminAccess, controllers.GetConnectedClients)) http.HandleFunc("/api/integrations/clients", middleware.RequireAccessToken(models.ScopeHasAdminAccess, controllers.GetConnectedClients))
// Logo path // Logo path
http.HandleFunc("/api/admin/config/logo", middleware.RequireAdminAuth(admin.SetLogoPath)) http.HandleFunc("/api/admin/config/logo", middleware.RequireAdminAuth(admin.SetLogoPath))
@ -189,6 +190,9 @@ func Start() error {
// reset the YP registration // reset the YP registration
http.HandleFunc("/api/admin/yp/reset", middleware.RequireAdminAuth(admin.ResetYPRegistration)) http.HandleFunc("/api/admin/yp/reset", middleware.RequireAdminAuth(admin.ResetYPRegistration))
// set external action links
http.HandleFunc("/api/admin/config/externalactions", middleware.RequireAdminAuth(admin.SetExternalActions))
port := config.WebServerPort port := config.WebServerPort
log.Infof("Web server is listening on port %d.", port) log.Infof("Web server is listening on port %d.", port)

BIN
webroot/img/loading.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 B

After

Width:  |  Height:  |  Size: 558 B

View File

@ -13,6 +13,9 @@ import {
hasTouchScreen, hasTouchScreen,
getOrientation, getOrientation,
} from './utils/helpers.js'; } from './utils/helpers.js';
import ExternalActionModal, {
ExternalActionButton,
} from './components/external-action-modal.js';
import { import {
addNewlines, addNewlines,
@ -73,6 +76,8 @@ export default class App extends Component {
windowWidth: window.innerWidth, windowWidth: window.innerWidth,
windowHeight: window.innerHeight, windowHeight: window.innerHeight,
orientation: getOrientation(this.hasTouchScreen), orientation: getOrientation(this.hasTouchScreen),
externalAction: null,
}; };
// timers // timers
@ -96,7 +101,10 @@ export default class App extends Component {
this.disableChatInput = this.disableChatInput.bind(this); this.disableChatInput = this.disableChatInput.bind(this);
this.setCurrentStreamDuration = this.setCurrentStreamDuration.bind(this); this.setCurrentStreamDuration = this.setCurrentStreamDuration.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleKeyPressed = this.handleKeyPressed.bind(this); this.handleKeyPressed = this.handleKeyPressed.bind(this);
this.displayExternalAction = this.displayExternalAction.bind(this);
this.closeExternalActionModal = this.closeExternalActionModal.bind(this);
// player events // player events
this.handlePlayerReady = this.handlePlayerReady.bind(this); this.handlePlayerReady = this.handlePlayerReady.bind(this);
@ -119,6 +127,7 @@ export default class App extends Component {
if (this.hasTouchScreen) { if (this.hasTouchScreen) {
window.addEventListener('orientationchange', this.handleWindowResize); window.addEventListener('orientationchange', this.handleWindowResize);
} }
window.addEventListener('keydown', this.handleKeyDown);
window.addEventListener('keypress', this.handleKeyPressed); window.addEventListener('keypress', this.handleKeyPressed);
this.player = new OwncastPlayer(); this.player = new OwncastPlayer();
this.player.setupPlayerCallbacks({ this.player.setupPlayerCallbacks({
@ -140,6 +149,7 @@ export default class App extends Component {
window.removeEventListener('resize', this.handleWindowResize); window.removeEventListener('resize', this.handleWindowResize);
window.removeEventListener('blur', this.handleWindowBlur); window.removeEventListener('blur', this.handleWindowBlur);
window.removeEventListener('focus', this.handleWindowFocus); window.removeEventListener('focus', this.handleWindowFocus);
window.removeEventListener('keydown', this.handleKeyDown);
window.removeEventListener('keypress', this.handleKeyPressed); window.removeEventListener('keypress', this.handleKeyPressed);
if (this.hasTouchScreen) { if (this.hasTouchScreen) {
window.removeEventListener('orientationchange', this.handleWindowResize); window.removeEventListener('orientationchange', this.handleWindowResize);
@ -386,6 +396,12 @@ export default class App extends Component {
} }
} }
handleKeyDown(e) {
if (e.code === 'Escape' && this.state.externalAction !== null) {
this.closeExternalActionModal();
}
}
handleKeyPressed(e) { handleKeyPressed(e) {
if ( if (
e.code === 'Space' && e.code === 'Space' &&
@ -396,6 +412,35 @@ export default class App extends Component {
} }
} }
displayExternalAction(index) {
const { configData, username } = this.state;
const action = configData.externalActions[index];
if (!action) {
return;
}
const { url: actionUrl, openExternally } = action || {};
let url = new URL(actionUrl);
// Append url and username to params so the link knows where we came from and who we are.
url.searchParams.append('username', username);
url.searchParams.append('instance', window.location);
if (openExternally) {
var win = window.open(url.toString(), '_blank');
win.focus();
return;
}
this.setState({
externalAction: action,
});
}
closeExternalActionModal() {
this.setState({
externalAction: null,
});
}
render(props, state) { render(props, state) {
const { const {
chatInputEnabled, chatInputEnabled,
@ -413,6 +458,7 @@ export default class App extends Component {
websocket, websocket,
windowHeight, windowHeight,
windowWidth, windowWidth,
externalAction,
} = state; } = state;
const { const {
@ -424,23 +470,12 @@ export default class App extends Component {
name, name,
extraPageContent, extraPageContent,
chatDisabled, chatDisabled,
externalActions,
} = configData; } = configData;
const bgUserLogo = { backgroundImage: `url(${logo})` }; const bgUserLogo = { backgroundImage: `url(${logo})` };
const tagList = const tagList = tags !== null && tags.length > 0 && tags.join(' #');
tags !== null && tags.length > 0
? tags.map(
(tag, index) => html`
<li
key="tag${index}"
class="tag rounded-sm text-gray-100 bg-gray-700 text-xs uppercase mr-3 mb-2 p-2 whitespace-no-wrap"
>
${tag}
</li>
`
)
: null;
const viewerCountMessage = const viewerCountMessage =
streamOnline && viewerCount > 0 streamOnline && viewerCount > 0
@ -470,6 +505,32 @@ export default class App extends Component {
? null ? null
: html` <${VideoPoster} offlineImage=${logo} active=${streamOnline} /> `; : html` <${VideoPoster} offlineImage=${logo} active=${streamOnline} /> `;
const externalActionButtons =
externalActions &&
html`<div
id="external-actions-container"
class="flex flex-row align-center"
>
${externalActions.map(
function (action, index) {
return html`<${ExternalActionButton}
onClick=${this.displayExternalAction}
action=${action}
index=${index}
/>`;
}.bind(this)
)}
</div>`;
const externalActionModal = externalAction
? html`<${ExternalActionModal}
title=${this.state.externalAction.description ||
this.state.externalAction.title}
url=${this.state.externalAction.url}
onClose=${this.closeExternalActionModal}
/>`
: null;
return html` return html`
<div <div
id="app-container" id="app-container"
@ -555,6 +616,7 @@ export default class App extends Component {
<div <div
class="user-content-header border-b border-gray-500 border-solid" class="user-content-header border-b border-gray-500 border-solid"
> >
${externalActionButtons}
<h2 class="font-semibold text-5xl"> <h2 class="font-semibold text-5xl">
<span class="streamer-name text-indigo-600">${name}</span> <span class="streamer-name text-indigo-600">${name}</span>
</h2> </h2>
@ -567,9 +629,9 @@ export default class App extends Component {
class="stream-summary my-4" class="stream-summary my-4"
dangerouslySetInnerHTML=${{ __html: summary }} dangerouslySetInnerHTML=${{ __html: summary }}
></div> ></div>
<ul id="tag-list" class="tag-list flex flex-row flex-wrap my-4"> <div id="tag-list" class="tag-list text-gray-600 mb-3">
${tagList} ${tagList && `#${tagList}`}
</ul> </div>
</div> </div>
</div> </div>
<div <div
@ -592,6 +654,7 @@ export default class App extends Component {
chatInputEnabled=${chatInputEnabled && !chatDisabled} chatInputEnabled=${chatInputEnabled && !chatDisabled}
instanceTitle=${name} instanceTitle=${name}
/> />
${externalActionModal}
</div> </div>
`; `;
} }

View File

@ -0,0 +1,86 @@
import { h } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
export default function ExternalActionModal({ url, title, onClose }) {
return html`
<div class="fixed inset-0 overflow-y-auto" style="z-index: 9999">
<div
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div
onClick=${() => onClose()}
class="absolute inset-0 bg-gray-900 bg-opacity-75"
></div>
</div>
<!-- This element is to trick the browser into centering the modal contents. -->
<span
class="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>&#8203;</span
>
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:align-middle w-screen md:max-w-2xl lg:max-w-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
>
<div class="bg-white ">
<div
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex justify-between items-center"
>
<h3 class="font-bold hidden md:block">${title}</h3>
<span class="" onclick=${onClose}>
<svg
class="h-12 w-12 fill-current text-grey hover:text-grey-darkest"
role="button"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<title>Close</title>
<path
d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"
/>
</svg>
</span>
</div>
<!-- TODO: Show a loading spinner while the iframe loads -->
<iframe
style="height: 70vh"
width="100%"
allowpaymentrequest="true"
allowfullscreen="false"
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
src=${url}
/>
</div>
</div>
</div>
</div>
`;
}
export function ExternalActionButton({ index, action, onClick }) {
const { title, icon, color = undefined } = action;
const logo =
icon &&
html`
<span class="external-action-icon"><img src=${icon} alt="" /></span>
`;
const bgcolor = color && { backgroundColor: `${color}` };
const handleClick = () => onClick(index);
return html`
<button
class="external-action-button rounded-sm flex flex-row justify-center items-center overflow-hidden bg-gray-800"
data-index=${index}
onClick=${handleClick}
style=${bgcolor}
>
${logo}
<span class="external-action-label">${title}</span>
</button>
`;
}

View File

@ -82,6 +82,37 @@ header {
display: inline; display: inline;
} }
#external-actions-container {
margin: 1em 0;
}
.external-action-button {
border-radius: 4px;
color: #fff;
font-size: .88em;
padding: .25em .75em;
margin: .35em;
margin-left: 0;
display: flex;
max-width: 250px;
padding-top: .3em;
}
.external-action-button:hover {
text-decoration: underline;
}
.external-action-icon {
margin: .25em .5em .25em 0;
}
.external-action-icon img {
height: 1.5em;
width: 1.5em;
}
.external-action-label {
text-overflow: ellipsis;
overflow: hidden;
}
/* ************************************************ */ /* ************************************************ */
@ -245,3 +276,7 @@ header {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
} }
iframe{
background:url(/img/loading.gif) center center no-repeat; height: 100%;
}