Chalkboard feature added.

This commit is contained in:
Storm Dragon
2026-05-24 22:36:27 -04:00
parent 0f5a19f31a
commit 0a149e51ef
9 changed files with 537 additions and 9 deletions
+20
View File
@@ -108,6 +108,12 @@ type Hall struct {
history []ChatHistoryEntry history []ChatHistoryEntry
timestamp time.Time timestamp time.Time
data map[string]interface{} data map[string]interface{}
chalkboard ChalkboardState
}
type ChalkboardState struct {
Text string `json:"text"`
Revision uint64 `json:"revision"`
} }
func (g *Hall) Name() string { func (g *Hall) Name() string {
@@ -165,6 +171,20 @@ func (g *Hall) UpdateData(d map[string]interface{}) {
} }
} }
func (g *Hall) Chalkboard() ChalkboardState {
g.mu.Lock()
defer g.mu.Unlock()
return g.chalkboard
}
func (g *Hall) UpdateChalkboard(text string) ChalkboardState {
g.mu.Lock()
defer g.mu.Unlock()
g.chalkboard.Text = text
g.chalkboard.Revision++
return g.chalkboard
}
func (g *Hall) Description() *Description { func (g *Hall) Description() *Description {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
+52 -2
View File
@@ -82,6 +82,28 @@ func broadcastHallInfo(g *hall.Hall, message string) {
} }
} }
func canEditChalkboard(perms []string) bool {
return member("op", perms) ||
member("admin", perms) ||
member("chalkboard", perms)
}
func chalkboardMessage(state hall.ChalkboardState) clientMessage {
return clientMessage{
Type: "usermessage",
Kind: "chalkboard",
Privileged: true,
Value: state,
}
}
func broadcastChalkboard(g *hall.Hall, state hall.ChalkboardState) {
err := broadcast(g.GetClients(nil), chalkboardMessage(state))
if err != nil {
log.Printf("broadcast(chalkboard): %v", err)
}
}
type webClient struct { type webClient struct {
hall *hall.Hall hall *hall.Hall
addr net.Addr addr net.Addr
@@ -1211,6 +1233,10 @@ func handleAction(c *webClient, a any) error {
return err return err
} }
} }
err := c.write(chalkboardMessage(g.Chalkboard()))
if err != nil {
return err
}
} }
case changePermissionsAction: case changePermissionsAction:
switch a.kind { switch a.kind {
@@ -1231,6 +1257,10 @@ func handleAction(c *webClient, a any) error {
c.permissions = remove("message", c.permissions) c.permissions = remove("message", c.permissions)
case "unshutup": case "unshutup":
c.permissions = addnew("message", c.permissions) c.permissions = addnew("message", c.permissions)
case "chalkboard":
c.permissions = addnew("chalkboard", c.permissions)
case "unchalkboard":
c.permissions = remove("chalkboard", c.permissions)
default: default:
return hall.UserError("unknown permission") return hall.UserError("unknown permission")
} }
@@ -1660,6 +1690,24 @@ func handleClientMessage(c *webClient, m clientMessage) error {
if err != nil { if err != nil {
log.Printf("broadcast(clearchat): %v", err) log.Printf("broadcast(clearchat): %v", err)
} }
case "chalkboard":
if !canEditChalkboard(c.permissions) {
return c.error(hall.UserError("not authorised"))
}
value, ok := m.Value.(map[string]any)
if !ok {
return c.error(hall.UserError(
"bad value in chalkboard",
))
}
text, ok := value["text"].(string)
if !ok {
return c.error(hall.UserError(
"bad value in chalkboard",
))
}
state := g.UpdateChalkboard(text)
broadcastChalkboard(g, state)
case "lock", "unlock": case "lock", "unlock":
if !member("op", c.permissions) { if !member("op", c.permissions) {
return c.error(hall.UserError("not authorised")) return c.error(hall.UserError("not authorised"))
@@ -1903,8 +1951,10 @@ func handleClientMessage(c *webClient, m clientMessage) error {
return c.error(hall.UserError("join a hall first")) return c.error(hall.UserError("join a hall first"))
} }
switch m.Kind { switch m.Kind {
case "op", "unop", "present", "unpresent", "shutup", "unshutup": case "op", "unop", "present", "unpresent", "shutup", "unshutup", "chalkboard", "unchalkboard":
if !member("op", c.permissions) { if !member("op", c.permissions) &&
!(member("admin", c.permissions) &&
(m.Kind == "chalkboard" || m.Kind == "unchalkboard")) {
return c.error(hall.UserError("not authorised")) return c.error(hall.UserError("not authorised"))
} }
t := g.GetClient(m.Dest) t := g.GetClient(m.Dest)
+245
View File
@@ -2,10 +2,15 @@ package rtpconn
import ( import (
"encoding/json" "encoding/json"
"os"
"path/filepath"
"reflect" "reflect"
"strings"
"testing" "testing"
"git.stormux.org/storm/skald/hall"
"git.stormux.org/storm/skald/token" "git.stormux.org/storm/skald/token"
"git.stormux.org/storm/skald/unbounded"
) )
var tokens = []string{ var tokens = []string{
@@ -50,3 +55,243 @@ func TestParseStatefulToken(t *testing.T) {
} }
} }
} }
func testWebClient(id string) *webClient {
return &webClient{
id: id,
actions: unbounded.New[any](),
done: make(chan struct{}),
writeCh: make(chan interface{}, 100),
writerDone: make(chan struct{}),
}
}
func makePermission(t *testing.T, name string) hall.Permissions {
t.Helper()
p, err := hall.NewPermissions(name)
if err != nil {
t.Fatalf("NewPermissions(%q): %v", name, err)
}
return p
}
func testPassword() hall.Password {
key := "pw"
return hall.Password{
Type: "plain",
Key: &key,
}
}
func addTestWebClient(t *testing.T, hallName string, username string, permission string) *webClient {
t.Helper()
c := testWebClient(username + "-id")
_, err := hall.AddClient(hallName, c, hall.ClientCredentials{
Username: &username,
Password: "pw",
})
if err != nil {
t.Fatalf("AddClient(%q): %v", username, err)
}
c.hall = hall.Get(hallName)
drainActions(t, c)
drainMessages(c)
if !member(permission, c.permissions) {
t.Fatalf("%q permissions %v, missing %q", username, c.permissions, permission)
}
return c
}
func newChalkboardTestHall(t *testing.T) string {
t.Helper()
name := "chalkboard-" + strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
oldDirectory := hall.Directory
hall.Directory = t.TempDir()
t.Cleanup(func() {
hall.Directory = oldDirectory
})
password := testPassword()
desc := &hall.Description{
Users: map[string]hall.UserDescription{
"Operator": {
Password: password,
Permissions: makePermission(t, "op"),
},
"Admin": {
Password: password,
Permissions: makePermission(t, "admin"),
},
"Participant": {
Password: password,
Permissions: makePermission(t, "present"),
},
},
}
data, err := json.Marshal(desc)
if err != nil {
t.Fatalf("Marshal hall description: %v", err)
}
if err := os.WriteFile(filepath.Join(hall.Directory, name+".json"), data, 0o666); err != nil {
t.Fatalf("Write hall description: %v", err)
}
if _, err := hall.Add(name, nil); err != nil {
t.Fatalf("Add hall: %v", err)
}
t.Cleanup(func() {
if g := hall.Get(name); g != nil {
for _, c := range g.GetClients(nil) {
hall.DelClient(c)
}
}
hall.Delete(name)
})
return name
}
func drainActions(t *testing.T, c *webClient) {
t.Helper()
for {
select {
case <-c.actions.Ch:
for _, a := range c.actions.Get() {
if err := handleAction(c, a); err != nil {
t.Fatalf("handleAction: %v", err)
}
}
default:
return
}
}
}
func drainMessages(c *webClient) []clientMessage {
var messages []clientMessage
for {
select {
case m := <-c.writeCh:
switch m := m.(type) {
case clientMessage:
messages = append(messages, m)
case []byte:
var cm clientMessage
if err := json.Unmarshal(m, &cm); err == nil {
messages = append(messages, cm)
}
}
default:
return messages
}
}
}
func latestChalkboardMessage(messages []clientMessage) (hall.ChalkboardState, bool) {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Type != "usermessage" || messages[i].Kind != "chalkboard" {
continue
}
b, err := json.Marshal(messages[i].Value)
if err != nil {
return hall.ChalkboardState{}, false
}
var state hall.ChalkboardState
if err := json.Unmarshal(b, &state); err != nil {
return hall.ChalkboardState{}, false
}
return state, true
}
return hall.ChalkboardState{}, false
}
func TestChalkboardEditRequiresPermission(t *testing.T) {
hallName := newChalkboardTestHall(t)
participant := addTestWebClient(t, hallName, "Participant", "present")
operator := addTestWebClient(t, hallName, "Operator", "op")
err := handleClientMessage(participant, clientMessage{
Type: "hallaction",
Kind: "chalkboard",
Value: map[string]any{
"text": "should not save",
"revision": float64(0),
},
})
if err != nil {
t.Fatalf("unauthorised chalkboard edit returned error: %v", err)
}
if got := participant.hall.Chalkboard().Text; got != "" {
t.Fatalf("unauthorised edit changed text to %q", got)
}
err = handleClientMessage(operator, clientMessage{
Type: "hallaction",
Kind: "chalkboard",
Value: map[string]any{
"text": "line 1\nline 2",
"revision": float64(0),
},
})
if err != nil {
t.Fatalf("operator chalkboard edit: %v", err)
}
state := operator.hall.Chalkboard()
if state.Text != "line 1\nline 2" || state.Revision != 1 {
t.Fatalf("chalkboard state = %#v", state)
}
for _, c := range []*webClient{participant, operator} {
got, ok := latestChalkboardMessage(drainMessages(c))
if !ok {
t.Fatalf("%v did not receive chalkboard broadcast", c.id)
}
if got != state {
t.Fatalf("%v broadcast = %#v, want %#v", c.id, got, state)
}
}
}
func TestChalkboardGrantIsSessionPermission(t *testing.T) {
hallName := newChalkboardTestHall(t)
admin := addTestWebClient(t, hallName, "Admin", "admin")
participant := addTestWebClient(t, hallName, "Participant", "present")
err := handleClientMessage(admin, clientMessage{
Type: "useraction",
Kind: "chalkboard",
Dest: participant.id,
})
if err != nil {
t.Fatalf("admin chalkboard grant: %v", err)
}
drainActions(t, participant)
if !member("chalkboard", participant.permissions) {
t.Fatalf("grant did not add chalkboard permission: %v", participant.permissions)
}
err = handleClientMessage(participant, clientMessage{
Type: "hallaction",
Kind: "chalkboard",
Value: map[string]any{
"text": "granted edit",
"revision": float64(0),
},
})
if err != nil {
t.Fatalf("granted chalkboard edit: %v", err)
}
if got := participant.hall.Chalkboard().Text; got != "granted edit" {
t.Fatalf("chalkboard text = %q", got)
}
err = handleClientMessage(admin, clientMessage{
Type: "useraction",
Kind: "unchalkboard",
Dest: participant.id,
})
if err != nil {
t.Fatalf("admin chalkboard revoke: %v", err)
}
drainActions(t, participant)
if member("chalkboard", participant.permissions) {
t.Fatalf("revoke left chalkboard permission: %v", participant.permissions)
}
}
+31 -5
View File
@@ -355,8 +355,21 @@ chat history, and is not expected to contain user-visible content.
``` ```
Currently defined kinds include `error`, `warning`, `info`, `kicked`, Currently defined kinds include `error`, `warning`, `info`, `kicked`,
`clearchat` (not to be confused with the `clearchat` hall action), and `clearchat` (not to be confused with the `clearchat` hall action),
`mute`. `chalkboard`, and `mute`. A `chalkboard` user message carries the current
virtual chalkboard state:
```javascript
{
type: 'usermessage',
kind: 'chalkboard',
privileged: true,
value: {
text: text,
revision: revision
}
}
```
A user action requests that the server act upon a user. A user action requests that the server act upon a user.
@@ -371,7 +384,7 @@ A user action requests that the server act upon a user.
} }
``` ```
Currently defined kinds include `op`, `unop`, `present`, `unpresent`, Currently defined kinds include `op`, `unop`, `present`, `unpresent`,
`kick` and `setdata`. `chalkboard`, `unchalkboard`, `kick` and `setdata`.
Finally, a hall action requests that the server act on the current hall. Finally, a hall action requests that the server act on the current hall.
@@ -386,8 +399,21 @@ Finally, a hall action requests that the server act on the current hall.
``` ```
Currently defined kinds include `clearchat` (not to be confused with the Currently defined kinds include `clearchat` (not to be confused with the
`clearchat` user message), `lock`, `unlock`, `record`, `unrecord`, `clearchat` user message), `chalkboard`, `lock`, `unlock`, `record`,
`subhalls` and `setdata`. `unrecord`, `subhalls` and `setdata`. A `chalkboard` hall action updates
the session-only virtual chalkboard and requires `op`, `admin`, or
temporary `chalkboard` permission:
```javascript
{
type: 'hallaction',
kind: 'chalkboard',
value: {
text: text,
revision: revision
}
}
```
# Peer-to-peer file transfer protocol # Peer-to-peer file transfer protocol
+8
View File
@@ -75,6 +75,14 @@ than navigating the user interface. Commands start with a slash character
message to a given user. Type `/help` to display the list of available message to a given user. Type `/help` to display the list of available
commands. commands.
Below the chat input is a session-only virtual chalkboard. It is a
multiline text area suitable for commands, code, and notes that should be
visible to everyone in the hall. Operators and administrators may edit
the chalkboard and may grant or revoke chalkboard editing for connected
users with `/chalkboard user` and `/unchalkboard user`. Users without
edit permission can read and focus the chalkboard, but it is marked
read-only and does not trap the Tab key.
### Inviting users ### Inviting users
In order to generate an invitation link, choose the entry *Invite user* in In order to generate an invitation link, choose the entry *Invite user* in
+1 -1
View File
@@ -781,7 +781,7 @@ ServerConnection.prototype.chat = function(kind, dest, value) {
/** /**
* userAction sends a request to act on a user. * userAction sends a request to act on a user.
* *
* @param {string} kind - One of "op", "unop", "kick", "present", "unpresent". * @param {string} kind - One of "op", "unop", "kick", "present", "unpresent", "chalkboard", "unchalkboard".
* @param {string} dest - The id of the user to act upon. * @param {string} dest - The id of the user to act upon.
* @param {any} [value] - An action-dependent parameter. * @param {any} [value] - An action-dependent parameter.
*/ */
+34 -1
View File
@@ -731,6 +731,8 @@ h1 {
#chatbox { #chatbox {
height: 100%; height: 100%;
position: relative; position: relative;
display: flex;
flex-direction: column;
} }
#chat { #chat {
@@ -754,7 +756,8 @@ h1 {
#box { #box {
overflow: auto; overflow: auto;
height: calc(100% - 53px); flex: 1 1 auto;
min-height: 6rem;
padding: 10px; padding: 10px;
} }
@@ -812,6 +815,36 @@ h1 {
width: 100%; width: 100%;
} }
#chalkboard-area {
border-top: 1px solid #d7d7d7;
background: #fff;
padding: 0.35rem;
}
#chalkboard-label {
display: block;
font-size: 0.9rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
#chalkboard {
box-sizing: border-box;
display: block;
width: 100%;
min-height: 8rem;
resize: vertical;
}
#chalkboard[readonly] {
background: #f3f3f3;
}
#chalkboard:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
.sidenav { .sidenav {
background-color: #4d076b; background-color: #4d076b;
box-shadow: 0 0 24px 0 rgba(71,77,86,.1), 0 1px 0 0 rgba(71,77,86,.08); box-shadow: 0 0 24px 0 rgba(71,77,86,.1), 0 1px 0 0 rgba(71,77,86,.08);
+4
View File
@@ -76,6 +76,10 @@
<input id="inputbutton" type="submit" value="&#10148;" class="btn btn-default" aria-label="Send message"/> <input id="inputbutton" type="submit" value="&#10148;" class="btn btn-default" aria-label="Send message"/>
</form> </form>
</div> </div>
<div id="chalkboard-area">
<label id="chalkboard-label" for="chalkboard">Virtual chalkboard</label>
<textarea id="chalkboard" aria-labelledby="chalkboard-label"></textarea>
</div>
</div> </div>
</div> </div>
</div> </div>
+142
View File
@@ -579,6 +579,7 @@ function gotClose(code, reason) {
closeUpMedia(); closeUpMedia();
closeSafariStream(); closeSafariStream();
setConnected(false); setConnected(false);
resetChalkboard();
if(code != 1000) { if(code != 1000) {
console.warn('Socket close', code, reason); console.warn('Socket close', code, reason);
} }
@@ -1802,6 +1803,19 @@ function userMenu(elt) {
serverConnection.userAction('identify', id); serverConnection.userAction('identify', id);
}}); }});
} }
if(serverConnection.permissions.indexOf('op') >= 0 ||
serverConnection.permissions.indexOf('admin') >= 0) {
if(serverConnection.permissions.indexOf('op') < 0)
items.push({type: 'seperator'}); // sic
if(user.permissions.indexOf('chalkboard') >= 0)
items.push({label: 'Forbid chalkboard editing', onClick: () => {
serverConnection.userAction('unchalkboard', id);
}});
else
items.push({label: 'Allow chalkboard editing', onClick: () => {
serverConnection.userAction('chalkboard', id);
}});
}
} }
openActionMenu(items, elt, `Actions for ${user.username || 'participant'}`); openActionMenu(items, elt, `Actions for ${user.username || 'participant'}`);
} }
@@ -1981,6 +1995,7 @@ function displayUsername() {
document.getElementById('userspan').textContent = serverConnection.username; document.getElementById('userspan').textContent = serverConnection.username;
let op = serverConnection.permissions.indexOf('op') >= 0; let op = serverConnection.permissions.indexOf('op') >= 0;
let present = serverConnection.permissions.indexOf('present') >= 0; let present = serverConnection.permissions.indexOf('present') >= 0;
let chalkboard = canEditChalkboard();
let text = ''; let text = '';
if(op && present) if(op && present)
text = '(op, presenter)'; text = '(op, presenter)';
@@ -1988,6 +2003,8 @@ function displayUsername() {
text = 'operator'; text = 'operator';
else if(present) else if(present)
text = 'presenter'; text = 'presenter';
else if(chalkboard)
text = 'chalkboard editor';
document.getElementById('permspan').textContent = text; document.getElementById('permspan').textContent = text;
} }
@@ -2098,6 +2115,7 @@ async function gotJoined(kind, hall, perms, status, data, error, message) {
setTitle((status && status.displayName) || capitalise(hall)); setTitle((status && status.displayName) || capitalise(hall));
setConnected(true); setConnected(true);
displayUsername(); displayUsername();
updateChalkboardAccess();
setButtonsVisibility(); setButtonsVisibility();
setChangePassword(pwAuth && !!hallStatus.canChangePassword && setChangePassword(pwAuth && !!hallStatus.canChangePassword &&
serverConnection.username serverConnection.username
@@ -2404,6 +2422,13 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa
clearChat(id, userId); clearChat(id, userId);
break; break;
} }
case 'chalkboard':
if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`);
return;
}
applyChalkboardUpdate(message);
break;
case 'token': case 'token':
if(!privileged) { if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`); console.error(`Got unprivileged message of kind ${kind}`);
@@ -2586,6 +2611,89 @@ function announceChat(text) {
announceLiveRegion(text, 'chat-announcements'); announceLiveRegion(text, 'chat-announcements');
} }
let chalkboardRevision = 0;
let chalkboardSendTimer = null;
let applyingChalkboardUpdate = false;
function canEditChalkboard() {
if(!serverConnection || !serverConnection.permissions)
return false;
return serverConnection.permissions.indexOf('op') >= 0 ||
serverConnection.permissions.indexOf('admin') >= 0 ||
serverConnection.permissions.indexOf('chalkboard') >= 0;
}
function updateChalkboardAccess() {
let chalkboard = /** @type {HTMLTextAreaElement} */
(document.getElementById('chalkboard'));
if(!chalkboard)
return;
let canEdit = canEditChalkboard();
chalkboard.readOnly = !canEdit;
chalkboard.setAttribute('aria-readonly', canEdit ? 'false' : 'true');
chalkboard.setAttribute(
'aria-label',
canEdit ? 'Virtual chalkboard' : 'Virtual chalkboard, read only',
);
}
function resetChalkboard() {
chalkboardRevision = 0;
if(chalkboardSendTimer) {
clearTimeout(chalkboardSendTimer);
chalkboardSendTimer = null;
}
let chalkboard = /** @type {HTMLTextAreaElement} */
(document.getElementById('chalkboard'));
if(chalkboard)
chalkboard.value = '';
updateChalkboardAccess();
}
/**
* @param {any} message
*/
function applyChalkboardUpdate(message) {
if(!message || typeof message !== 'object') {
console.warn('Unexpected chalkboard message', message);
return;
}
let text = typeof message.text === 'string' ? message.text : '';
let revision = Number(message.revision || 0);
if(revision < chalkboardRevision)
return;
chalkboardRevision = revision;
let chalkboard = /** @type {HTMLTextAreaElement} */
(document.getElementById('chalkboard'));
if(!chalkboard)
return;
if(chalkboard.value !== text) {
applyingChalkboardUpdate = true;
chalkboard.value = text;
applyingChalkboardUpdate = false;
}
updateChalkboardAccess();
}
function scheduleChalkboardSend() {
if(!canEditChalkboard())
return;
if(chalkboardSendTimer)
clearTimeout(chalkboardSendTimer);
chalkboardSendTimer = setTimeout(() => {
chalkboardSendTimer = null;
let chalkboard = /** @type {HTMLTextAreaElement} */
(document.getElementById('chalkboard'));
if(!chalkboard || !serverConnection || !serverConnection.socket)
return;
serverConnection.hallAction('chalkboard', {
text: chalkboard.value,
revision: chalkboardRevision,
});
}, 350);
}
/** /**
* @param {string} text * @param {string} text
*/ */
@@ -2866,6 +2974,14 @@ function operatorPredicate() {
return 'You are not an operator'; return 'You are not an operator';
} }
function chalkboardGrantPredicate() {
if(serverConnection && serverConnection.permissions &&
(serverConnection.permissions.indexOf('op') >= 0 ||
serverConnection.permissions.indexOf('admin') >= 0))
return null;
return 'You are not allowed to grant chalkboard editing';
}
function recordingPredicate() { function recordingPredicate() {
if(serverConnection && serverConnection.permissions && if(serverConnection && serverConnection.permissions &&
serverConnection.permissions.indexOf('record') >= 0) serverConnection.permissions.indexOf('record') >= 0)
@@ -3311,6 +3427,20 @@ commands.unshutup = {
f: userCommand, f: userCommand,
}; };
commands.chalkboard = {
parameters: 'user',
description: 'give the right to edit the virtual chalkboard',
predicate: chalkboardGrantPredicate,
f: userCommand,
};
commands.unchalkboard = {
parameters: 'user',
description: 'revoke the right to edit the virtual chalkboard',
predicate: chalkboardGrantPredicate,
f: userCommand,
};
commands.mute = { commands.mute = {
parameters: 'user', parameters: 'user',
description: 'mute a remote user', description: 'mute a remote user',
@@ -3544,6 +3674,17 @@ document.getElementById('input').onkeypress = function(e) {
} }
}; };
document.getElementById('chalkboard').addEventListener('input', function(e) {
if(applyingChalkboardUpdate)
return;
scheduleChalkboardSend();
});
document.getElementById('chalkboard').addEventListener('keydown', function(e) {
if(e.key === 'Tab')
return;
});
function updateChatResizerValue(leftPercent) { function updateChatResizerValue(leftPercent) {
let resizer = document.getElementById('resizer'); let resizer = document.getElementById('resizer');
if(!resizer) if(!resizer)
@@ -3981,4 +4122,5 @@ document.addEventListener('keydown', function(e) {
} }
}); });
resetChalkboard();
start(); start();