Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Changelog

## 1.0.3 [UNRELEASED]
### Added
- A browser widget for custom embeds
### Changed
- Improved stability and UX of the agent plugin
### Fixed
Expand Down
15 changes: 3 additions & 12 deletions api/widgets.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,13 @@ class Widgets {
/**
* Make a widget available
* to the application
* @param {
* id: String,
* name: String,
* uri: String,
* description: String
* } spec
* @param { import('../shared/definitions.js').BridgeApiWidgetSpec } spec
*/
registerWidget (spec) {
this.#props.State.apply({
_widgets: {
[spec.id]: spec
}
})
this.#props.Commands.executeCommand('widgets.registerWidget', spec)
}
}

DIController.main.register('Widgets', Widgets, [
'State'
'Commands'
])
5 changes: 5 additions & 0 deletions app/App.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react'
import { Start } from './views/Start'
import { Error } from './views/Error'
import { Workspace } from './views/Workspace'
import { WorkspaceWidget } from './views/WorkspaceWidget'

Expand Down Expand Up @@ -272,6 +273,10 @@ export default function App () {
path: /^\/workspaces\/.+$/,
render: () => <Workspace />
},
{
path: /^\/error(?:\/.*)?$/,
render: () => <Error />
},
{
path: '/',
render: () => <Start />
Expand Down
25 changes: 25 additions & 0 deletions app/components/BrowserComponent/BrowserComponentHeader.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.BrowserComponentHeader {
display: flex;
padding: 5px;
width: 100%;
gap: 5px;

align-items: stretch;
box-sizing: border-box;
}

.BrowserComponentHeader-section--address {
width: 100%;
}

.BrowserComponentHeader-addressInput {
width: 100%;
}

.BrowserComponentHeader .Button {
height: 100%;
padding-top: 0;
padding-bottom: 0;

align-items: center;
}
47 changes: 47 additions & 0 deletions app/components/BrowserComponent/BrowserComponentHeader.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@

import React from 'react'
import './BrowserComponentHeader.css'

export const BrowserComponentHeader = ({ uri, onChange = () => {} }) => {
const [draftUri, setDraftUri] = React.useState(uri || '')

React.useEffect(() => {
setDraftUri(uri || '')
}, [uri])

function normalizeUri (value) {
const trimmedValue = value.trim()

if (!trimmedValue) {
return ''
}

if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmedValue)) {
return trimmedValue
}

return `https://${trimmedValue}`
}

function handleSubmit (e) {
e.preventDefault()
onChange(normalizeUri(draftUri))
}

return (
<form className='BrowserComponentHeader' onSubmit={handleSubmit}>
<div className='BrowserComponentHeader-section BrowserComponentHeader-section--address'>
<input
className='BrowserComponentHeader-addressInput Input'
type='text'
value={draftUri}
placeholder='Enter a URL'
onChange={e => setDraftUri(e.target.value)}
/>
</div>
<div className='BrowserComponentHeader-section'>
<button className='Button Button--secondary' type='submit'>Go</button>
</div>
</form>
)
}
40 changes: 40 additions & 0 deletions app/components/BrowserComponent/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react'
import './style.css'

import { BrowserComponentHeader } from './BrowserComponentHeader'

const BASE_HOST = `${window.location.protocol}//${window.location.host}`

export const BrowserComponent = ({ data = {}, onUpdate = () => {} }) => {
const [isLoading, setIsLoading] = React.useState(Boolean(data?.uri))

React.useEffect(() => {
setIsLoading(Boolean(data?.uri))
}, [data?.uri])

return (
<div className='BrowserComponent'>
<div className='BrowserComponent-header'>
<BrowserComponentHeader uri={data?.uri} onChange={newUri => onUpdate({ uri: newUri })} />
{
isLoading &&
<div className='BrowserComponent-loadingBar' />
}
</div>
<div className='BrowserComponent-frameContainer'>
{
data?.uri &&
<iframe
className='BrowserComponent-frame'
src={`${BASE_HOST}/embed?url=${encodeURIComponent(data?.uri)}`}
title={data?.uri}
sandbox='allow-downloads allow-forms allow-modals allow-popups allow-presentation allow-scripts allow-same-origin'
onLoad={() => setIsLoading(false)}
/>
}
</div>
</div>
)
}

<iframe allow="autoplay *; encrypted-media *;" frameborder="0" height="150" style="width:100%;max-width:660px;overflow:hidden;background:transparent;" sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation" src="https://embed.music.apple.com/se/album/levitating-live-from-mexico/6772178540?i=6772178817"></iframe>
64 changes: 64 additions & 0 deletions app/components/BrowserComponent/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
.BrowserComponent {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;

overflow: hidden;
}

.BrowserComponent-header {
position: relative;
width: 100%;

flex-shrink: 0;
}

.BrowserComponent-loadingBar {
position: absolute;
width: 100%;
height: 2px;
bottom: 0;

overflow: hidden;
flex-shrink: 0;
}

.BrowserComponent-loadingBar::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 35%;
height: 100%;

background: var(--base-color);
animation: BrowserComponent-loadingBar 1s ease-in-out infinite;
}

.BrowserComponent-frameContainer {
position: relative;
width: 100%;
height: 100%;

flex: 1;
min-height: 0;
}

.BrowserComponent-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
}

@keyframes BrowserComponent-loadingBar {
from {
transform: translateX(-100%);
}

to {
transform: translateX(400%);
}
}
16 changes: 16 additions & 0 deletions app/components/ErrorMessage/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react'
import './style.css'

import icon from '../../assets/icons/warning.svg'

export const ErrorMessage = ({ heading, message }) => {
return (
<div className='ErrorMessage'>
<div className='ErrorMessage-container'>
<div dangerouslySetInnerHTML={{ __html: icon }} />
<div className='ErrorMessage-heading'>{heading}</div>
{message}
</div>
</div>
)
}
19 changes: 19 additions & 0 deletions app/components/ErrorMessage/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@


.ErrorMessage {
display: flex;
width: 100%;
height: 100%;

text-align: center;
align-items: center;
}

.ErrorMessage-container {
width: 100%;
}

.ErrorMessage-heading {
margin: 10px 0 3px;
font-weight: 600;
}
4 changes: 4 additions & 0 deletions app/components/WidgetRenderer/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { GridItem } from '../GridItem'
import { TabsComponent } from '../TabsComponent'
import { EmptyComponent } from '../EmptyComponent'
import { FrameComponent } from '../FrameComponent'
import { BrowserComponent } from '../BrowserComponent'
import { MissingComponent } from '../MissingComponent'

/**
Expand Down Expand Up @@ -42,6 +43,9 @@ const INTERNAL_COMPONENTS = {
'bridge.internals.tabs': (widgetId, data, onUpdate, widgets) => {
return <TabsComponent widgetId={widgetId} data={data} widgets={widgets} onUpdate={onUpdate} />
},
'bridge.internals.browser': (widgetId, data, onUpdate, widgets) => {
return <BrowserComponent widgetId={widgetId} data={data} widgets={widgets} onUpdate={onUpdate} />
},
'bridge.internals.empty': () => {
return <EmptyComponent />
}
Expand Down
15 changes: 15 additions & 0 deletions app/views/Error.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* @author Axel Boberg
*/

import React from 'react'
import { ErrorMessage } from '../components/ErrorMessage'

export const Error = () => {
const heading = new URLSearchParams(window.location.search).get('heading') || 'Error'
const message = new URLSearchParams(window.location.search).get('message') || 'An unexpected error occurred'

return (
<ErrorMessage heading={heading} message={message} />
)
}
57 changes: 57 additions & 0 deletions lib/api/SWidgets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: 2026 Axel Boberg
//
// SPDX-License-Identifier: MIT

/**
* @file Implements the widget API
* @description This API is INTERNAL and may change anytime,
* it is not meant to be used by plugin developers
*/

const DIController = require('../../shared/DIController')
const DIBase = require('../../shared/DIBase')

const Logger = require('../Logger')
const logger = new Logger({ name: 'Widget api' })

class SWidgets extends DIBase {
constructor (...args) {
super(...args)
this.#setup()
}

#setup () {
this.props.SCommands.registerAsyncCommand('widgets.registerWidget', this.registerWidget.bind(this))
this.#registerInternalWidgets()
}

/**
* Register any internal widgets that
* should be usable from the UI
*/
#registerInternalWidgets () {
this.registerWidget({
id: 'bridge.internals.browser',
name: 'Embed',
description: 'Embed a web page'
})
}

/**
* Register a widget to the state
* @param { import('../../shared/definitions.js').BridgeApiWidgetSpec } spec
*/
registerWidget (spec = {}) {
logger.debug('Registering widget', spec?.id)
this.props.SState.applyState({
_widgets: {
[spec.id]: spec
}
})
}
}

DIController.main.register('SWidgets', SWidgets, [
'SCommands',
'SState'
])
3 changes: 3 additions & 0 deletions lib/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require('./SEvents')
require('./SServer')
require('./SSystem')
require('./SWindow')
require('./SWidgets')
require('./SSettings')
require('./SCommands')
require('./SMessages')
Expand All @@ -31,6 +32,7 @@ class SAPI {
this.commands = props.SCommands
this.messages = props.SMessages
this.settings = props.SSettings
this.widgets = props.SWidgets
this.events = props.SEvents
this.client = props.SClient
this.server = props.SServer
Expand All @@ -50,6 +52,7 @@ DIController.main.register('SAPI', SAPI, [
'SCommands',
'SMessages',
'SSettings',
'SWidgets',
'SEvents',
'SClient',
'SServer',
Expand Down
Loading
Loading