Chalkboard feature added.
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -76,6 +76,10 @@
|
|||||||
<input id="inputbutton" type="submit" value="➤" class="btn btn-default" aria-label="Send message"/>
|
<input id="inputbutton" type="submit" value="➤" 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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user