Browse Source

Add raised hand feature, connection stats, and host kick capability (#292)

* MeetRoom: clock, raised hands, kick, invite dialog, ArozOS attachments, stats

Add seven participant-facing features to the MeetRoom app:

- Header now shows the current wall-clock time and the live meeting
  duration (backend exposes the room CreatedAt in the descriptor).
- Raise-hand control: a hand state rides the existing state broadcast,
  shows an animated badge on the raiser's tile and in the participant
  list, and chimes with a system note when a remote hand goes up.
- Richer invite dialog replacing the copy-to-clipboard button: meeting
  title, ID, a shareable link, an optional personal message and one-tap
  copy buttons.
- Attach menu lets users share a file straight from their ArozOS
  storage (new /system/meetroom/attachfile endpoint streams the file
  server-side from the user's file system) alongside device upload.
- Host can remove participants: a kick control in the participant list,
  a host-only kick frame, and Room.KickParticipant (with test) that
  refuses to kick the host. The target gets a dedicated "kicked" frame
  so it stops auto-reconnecting.
- Connection performance panel polls RTCPeerConnection.getStats() for
  per-peer send/receive bitrate, round-trip time and packet loss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138Pxpde8t93BUip72hfLq3

* MeetRoom: float the stats window, add a raise-hand queue, fix hand-badge centering

Address UI feedback on the MeetRoom features:

- The raised-hand badge glyph sat low-right inside its round tile badge;
  nudge it to optically centre in the circle.
- Turn the connection-stats side panel into a compact floating window
  ("minimized page") that overlays the meeting and can be dragged around
  by its header (pointer events, so mouse and touch both work). It no
  longer displaces the video grid or fights the chat / participants
  panels, and defaults to the top-left clear of them.
- Add a raise-hand queue: the participants panel now lists everyone with
  a hand up in the order they raised it, numbered, above the roster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138Pxpde8t93BUip72hfLq3

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 3 tuần trước cách đây
mục cha
commit
c33bf185d7

+ 102 - 4
src/meetroom.go

@@ -12,6 +12,7 @@ package main
 	  GET  /system/meetroom/info?roomid=XXXXXXXXX             --> {"exists":..,"protected":..}
 	  GET  /system/meetroom/ws?roomid=&password=              --> WebSocket signaling upgrade
 	  POST /system/meetroom/upload      multipart (file)      --> {"fileid":...}
+	  POST /system/meetroom/attachfile  roomid=&password=&path=  --> {"fileid":...}
 	  GET  /system/meetroom/download?roomid=&password=&fileid=[&inline=1]
 	  GET  /system/meetroom/end?roomid=                       --> host ends the meeting
 	  GET  /system/meetroom/iceservers                        --> WebRTC ICE config
@@ -20,15 +21,16 @@ package main
 	  client -> server: {"type":"signal","to":peerid,"data":{...}}   SDP/ICE relay
 	                    {"type":"chat","text":"..."}
 	                    {"type":"file","fileid":"..."}               announce uploaded file
-	                    {"type":"state","audio":b,"video":b,"screen":b}
+	                    {"type":"state","audio":b,"video":b,"screen":b,"hand":b}
 	                    {"type":"attendance"}                        request the join/leave log
+	                    {"type":"kick","to":peerid}                  host only
 	                    {"type":"ping"}                              app-level heartbeat
 	                    {"type":"end"}                               host only
 	  server -> client: {"type":"welcome",...}, {"type":"peer-join",...},
-	                    {"type":"peer-leave",...}, {"type":"signal","from":..},
+	                    {"type":"peer-leave",...,"kicked":b}, {"type":"signal","from":..},
 	                    {"type":"chat",...}, {"type":"file",...},
 	                    {"type":"state",...}, {"type":"attendance",...},
-	                    {"type":"pong"}, {"type":"room-closed"}
+	                    {"type":"kicked"}, {"type":"pong"}, {"type":"room-closed"}
 
 	Shared space integration: every room owns a mod/sharedspace space (its ID
 	travels with the room). Chat and uploads mirror into the space with
@@ -54,6 +56,7 @@ import (
 	"net/http"
 	"net/url"
 	"os"
+	"path"
 	"strings"
 	"time"
 
@@ -96,6 +99,7 @@ type mrRoomInfo struct {
 	Title     string `json:"title"`
 	Host      string `json:"host"`
 	Protected bool   `json:"protected"`
+	CreatedAt int64  `json:"createdat"` // unix seconds; lets the client show the meeting duration
 }
 
 func mrDescribeRoom(room *meetroom.Room) mrRoomInfo {
@@ -105,6 +109,7 @@ func mrDescribeRoom(room *meetroom.Room) mrRoomInfo {
 		Title:     room.Title,
 		Host:      room.Host,
 		Protected: room.HasPassword(),
+		CreatedAt: room.CreatedAt.Unix(),
 	}
 }
 
@@ -136,6 +141,7 @@ func mrAttendanceList(room *meetroom.Room) []map[string]interface{} {
 	for _, record := range records {
 		entry := map[string]interface{}{
 			"username": record.Username,
+			"peerid":   record.PeerID, // lets the host target this attendee for a kick
 			"joinedat": record.JoinedAt.Unix(),
 			"present":  record.Present(),
 			"leftat":   int64(0),
@@ -344,6 +350,72 @@ func MeetRoomInit() {
 		utils.SendJSONResponse(w, string(js))
 	})
 
+	//Attach a file the user already owns in their ArozOS storage, addressed by
+	//virtual path (e.g. user:/Desktop/report.pdf), without a round-trip
+	//download+reupload. The file is streamed straight from the user's file
+	//system into the room's attachment store. Access is inherently scoped:
+	//GetFileSystemHandlerFromVirtualPath only resolves storages the user can
+	//reach, and the real path is translated for that user.
+	router.HandleFunc("/system/meetroom/attachfile", func(w http.ResponseWriter, r *http.Request) {
+		userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
+		if err != nil {
+			utils.SendErrorResponse(w, "Not logged in")
+			return
+		}
+		roomID := meetroom.NormalizeRoomID(r.FormValue("roomid"))
+		password := r.FormValue("password")
+		if _, err := meetRoomManager.ValidateJoin(roomID, password); err != nil {
+			utils.SendErrorResponse(w, err.Error())
+			return
+		}
+		vpath, err := utils.PostPara(r, "path")
+		if err != nil {
+			utils.SendErrorResponse(w, "Missing file path")
+			return
+		}
+
+		//Resolve the virtual path within the requesting user's file system
+		fsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(vpath)
+		if err != nil {
+			utils.SendErrorResponse(w, "File not accessible")
+			return
+		}
+		fshAbs := fsh.FileSystemAbstraction
+		rpath, err := fshAbs.VirtualPathToRealPath(vpath, userinfo.Username)
+		if err != nil {
+			utils.SendErrorResponse(w, "Invalid file path")
+			return
+		}
+		fileStat, err := fshAbs.Stat(rpath)
+		if err != nil {
+			utils.SendErrorResponse(w, "File not found")
+			return
+		}
+		if fileStat.IsDir() {
+			utils.SendErrorResponse(w, "Cannot attach a folder")
+			return
+		}
+
+		stream, err := fshAbs.ReadStream(rpath)
+		if err != nil {
+			utils.SendErrorResponse(w, "Could not open the file")
+			return
+		}
+		defer stream.Close()
+
+		attachment, err := meetRoomManager.SaveAttachment(roomID, path.Base(vpath), userinfo.Username, stream, meetroom.DefaultMaxUpload)
+		if err != nil {
+			utils.SendErrorResponse(w, err.Error())
+			return
+		}
+		js := mrMarshalOrDrop(map[string]interface{}{
+			"fileid": attachment.ID,
+			"name":   attachment.Name,
+			"size":   attachment.Size,
+		})
+		utils.SendJSONResponse(w, string(js))
+	})
+
 	//Attachment download for room members.
 	router.HandleFunc("/system/meetroom/download", func(w http.ResponseWriter, r *http.Request) {
 		roomID := meetroom.NormalizeRoomID(r.URL.Query().Get("roomid"))
@@ -508,6 +580,7 @@ func MeetRoomInit() {
 				Audio  bool            `json:"audio"`
 				Video  bool            `json:"video"`
 				Screen bool            `json:"screen"`
+				Hand   bool            `json:"hand"`
 			}
 			if json.Unmarshal(raw, &frame) != nil {
 				continue
@@ -555,13 +628,14 @@ func MeetRoomInit() {
 					"time":     time.Now().Unix(),
 				}), -1)
 			case "state":
-				//Mic / camera / screen share indicator update
+				//Mic / camera / screen share / raised-hand indicator update
 				room.Broadcast(mrMarshalOrDrop(map[string]interface{}{
 					"type":   "state",
 					"from":   participant.PeerID,
 					"audio":  frame.Audio,
 					"video":  frame.Video,
 					"screen": frame.Screen,
+					"hand":   frame.Hand,
 				}), participant.PeerID)
 			case "attendance":
 				//Send the requester the room's join/leave log (the
@@ -575,6 +649,30 @@ func MeetRoomInit() {
 				//App-level heartbeat: lets the client detect a half-dead
 				//connection and trigger its auto-reconnect logic.
 				room.SendTo(participant.PeerID, []byte(`{"type":"pong"}`))
+			case "kick":
+				//Host removes another participant. Order matters: the target
+				//is told it was kicked (so its client stops auto-reconnecting)
+				//while it is still subscribed, so the frame is queued and
+				//drained before its socket drops; everyone is then told it
+				//left; finally KickParticipant unregisters it and closes its
+				//send channel, which ends its writer goroutine and hangs up.
+				if !participant.IsHost || frame.To == participant.PeerID {
+					continue
+				}
+				target, ok := room.GetParticipant(frame.To)
+				if !ok || target.IsHost {
+					continue
+				}
+				room.SendTo(target.PeerID, []byte(`{"type":"kicked"}`))
+				//Everyone but the target hears the removal; the target gets the
+				//dedicated "kicked" frame above instead of its own leave.
+				room.Broadcast(mrMarshalOrDrop(map[string]interface{}{
+					"type":     "peer-leave",
+					"peerid":   target.PeerID,
+					"username": target.Username,
+					"kicked":   true,
+				}), target.PeerID)
+				room.KickParticipant(target.PeerID)
 			case "end":
 				if participant.IsHost {
 					mrEndMeeting(room.ID)

+ 18 - 0
src/mod/meetroom/meetroom.go

@@ -383,6 +383,24 @@ func (r *Room) RemoveParticipant(peerID int) {
 	}
 }
 
+// KickParticipant removes a participant on the host's behalf, atomically
+// looking it up and unregistering it. It returns the removed participant so
+// the transport layer can announce the removal, and reports whether a
+// participant with that peer ID was present. Kicking marks the attendance
+// record as left, exactly like an ordinary leave. The host cannot kick
+// themselves: a request targeting the room host is refused.
+func (r *Room) KickParticipant(peerID int) (*Participant, bool) {
+	r.mu.Lock()
+	p, ok := r.participants[peerID]
+	if !ok || p.IsHost {
+		r.mu.Unlock()
+		return nil, false
+	}
+	r.mu.Unlock()
+	r.RemoveParticipant(peerID)
+	return p, true
+}
+
 // Attendance returns a snapshot of the room's join / leave log in
 // chronological join order.
 func (r *Room) Attendance() []AttendanceRecord {

+ 42 - 0
src/mod/meetroom/meetroom_test.go

@@ -154,6 +154,48 @@ func TestParticipantLifecycle(t *testing.T) {
 	room.RemoveParticipant(guest.PeerID)
 }
 
+func TestKickParticipant(t *testing.T) {
+	m := newTestManager(t)
+	room := m.CreateRoom("alice", "", "")
+	host, _ := room.AddParticipant("alice")
+	guest, _ := room.AddParticipant("bob")
+
+	//The host cannot be kicked, even by peer ID
+	if _, ok := room.KickParticipant(host.PeerID); ok {
+		t.Errorf("KickParticipant(host) = true, want false")
+	}
+	if room.ParticipantCount() != 2 {
+		t.Errorf("ParticipantCount() after failed host kick = %d, want 2", room.ParticipantCount())
+	}
+
+	//A regular guest is removed and returned
+	kicked, ok := room.KickParticipant(guest.PeerID)
+	if !ok || kicked != guest {
+		t.Fatalf("KickParticipant(guest) = (%v, %v), want the guest / true", kicked, ok)
+	}
+	if room.ParticipantCount() != 1 {
+		t.Errorf("ParticipantCount() after kick = %d, want 1", room.ParticipantCount())
+	}
+	if _, open := <-guest.Send; open {
+		t.Errorf("kicked participant's send channel still open")
+	}
+
+	//The kick is recorded in the attendance log as a leave
+	for _, record := range room.Attendance() {
+		if record.PeerID == guest.PeerID && record.Present() {
+			t.Errorf("kicked participant still marked present in attendance")
+		}
+	}
+
+	//Kicking an unknown peer, or the same peer twice, is a safe no-op
+	if _, ok := room.KickParticipant(guest.PeerID); ok {
+		t.Errorf("KickParticipant(already kicked) = true, want false")
+	}
+	if _, ok := room.KickParticipant(9999); ok {
+		t.Errorf("KickParticipant(unknown) = true, want false")
+	}
+}
+
 func TestBroadcastAndSendTo(t *testing.T) {
 	m := newTestManager(t)
 	room := m.CreateRoom("alice", "", "")

+ 477 - 1
src/web/MeetRoom/app.css

@@ -95,6 +95,35 @@ body {
 .room-meta {
     flex: 0 0 auto;
     color: #9aa4b2;
+    display: flex;
+    align-items: center;
+    gap: 1em;
+}
+
+.room-meta .meta-item {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.3em;
+    white-space: nowrap;
+    font-variant-numeric: tabular-nums;
+}
+
+.room-meta .meta-item .icon {
+    margin: 0;
+    opacity: 0.75;
+}
+
+/* Meeting duration is the primary at-a-glance value */
+#meetingElapsed {
+    color: #e6e8ec;
+    font-weight: bold;
+}
+
+/* On narrow layouts the wall clock is the first thing to drop */
+@media (max-width: 520px) {
+    .room-meta .meta-item.meta-item:nth-child(2) {
+        display: none;
+    }
 }
 
 #reconnectBanner {
@@ -205,6 +234,42 @@ body {
     display: flex;
 }
 
+/* Raised-hand badge (top-right of a tile) */
+.video-tile .hand-badge {
+    position: absolute;
+    top: 8px;
+    right: 8px;
+    background: #f2b134;
+    color: #3a2a05;
+    width: 30px;
+    height: 30px;
+    border-radius: 50%;
+    display: none;
+    align-items: center;
+    justify-content: center;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
+    animation: handWave 1.6s ease-in-out infinite;
+}
+
+.video-tile .hand-badge .icon {
+    margin: 0;
+    width: auto;
+    font-size: 1em;
+    line-height: 1;
+    /* The Semantic hand glyph sits low-right inside its em box; nudge it to
+       optically centre it within the round badge. */
+    transform: translate(-0.5px, -2.5px);
+}
+
+.video-tile.hand-raised .hand-badge {
+    display: flex;
+}
+
+@keyframes handWave {
+    0%, 100% { transform: rotate(-8deg); }
+    50% { transform: rotate(8deg); }
+}
+
 /* Chat panel */
 #chatPanel {
     flex: 0 0 320px;
@@ -297,7 +362,7 @@ body {
     border-top: 1px solid #2b2f38;
 }
 
-/* Participants / attendance panel */
+/* Participants / attendance side panel */
 #peoplePanel {
     flex: 0 0 320px;
     display: flex;
@@ -365,6 +430,417 @@ body {
     white-space: nowrap;
 }
 
+/* Raised-hand marker in the participant list */
+.attendance-entry .hand-indicator.icon {
+    flex: 0 0 auto;
+    color: #f2b134;
+    margin: 0;
+}
+
+/* Raise-hand queue entries (ordered, at the top of the participant list) */
+.hand-queue-entry {
+    display: flex;
+    align-items: center;
+    gap: 0.6em;
+    background: #2c2a1e;
+    border: 1px solid #4a4020;
+    border-radius: 8px;
+    padding: 6px 10px;
+    margin-bottom: 6px;
+}
+
+.hand-queue-entry .hand-queue-pos {
+    flex: 0 0 auto;
+    width: 20px;
+    height: 20px;
+    line-height: 20px;
+    text-align: center;
+    border-radius: 50%;
+    background: #f2b134;
+    color: #3a2a05;
+    font-size: 0.78em;
+    font-weight: bold;
+}
+
+.hand-queue-entry .hand-indicator.icon {
+    flex: 0 0 auto;
+    color: #f2b134;
+    margin: 0;
+}
+
+.hand-queue-entry .attendee-name {
+    flex: 1 1 auto;
+    min-width: 0;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+/* Host-only kick button */
+.attendance-entry .kick-btn {
+    flex: 0 0 auto;
+    background: transparent;
+    border: none;
+    color: #9aa4b2;
+    cursor: pointer;
+    padding: 2px 4px;
+    border-radius: 6px;
+    line-height: 1;
+}
+
+.attendance-entry .kick-btn:hover {
+    background: #5c2b2b;
+    color: #ffb3ab;
+}
+
+.attendance-entry .kick-btn .icon {
+    margin: 0;
+    color: inherit;
+}
+
+/* Attachment source menu (pops above the chat input) */
+#attachMenu {
+    position: absolute;
+    left: 0.7em;
+    right: 0.7em;
+    bottom: 100%;
+    margin-bottom: 6px;
+    background: #262a33;
+    border: 1px solid #3a3f4a;
+    border-radius: 8px;
+    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
+    overflow: hidden;
+    z-index: 25;
+}
+
+.chat-input {
+    position: relative;
+}
+
+.attach-menu-item {
+    display: flex;
+    align-items: center;
+    gap: 0.6em;
+    width: 100%;
+    background: transparent;
+    border: none;
+    color: #e6e8ec;
+    text-align: left;
+    padding: 10px 14px;
+    cursor: pointer;
+    font-family: inherit;
+    font-size: 0.9em;
+}
+
+.attach-menu-item:hover {
+    background: #313641;
+}
+
+.attach-menu-item .icon {
+    margin: 0;
+    color: #7db4ff;
+}
+
+/* Connection stats: a compact floating window that overlays the meeting.
+   Defaults to the top-left so it stays clear of the right-docked chat /
+   participants panels; the user can drag it anywhere by its header. */
+#statsPanel {
+    position: absolute;
+    top: 60px;
+    left: 14px;
+    width: 264px;
+    max-width: calc(100% - 28px);
+    max-height: calc(100% - 150px);
+    background: #1e2128;
+    border: 1px solid #3a3f4a;
+    border-radius: 10px;
+    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55);
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    z-index: 35;
+}
+
+#statsHeader {
+    flex: 0 0 auto;
+    display: flex;
+    align-items: center;
+    gap: 0.4em;
+    padding: 0.6em 0.9em;
+    font-weight: bold;
+    font-size: 0.9em;
+    border-bottom: 1px solid #2b2f38;
+    cursor: move;
+    user-select: none;
+    -webkit-user-select: none;
+    touch-action: none;
+}
+
+#statsHeader .chart.line.icon {
+    margin: 0;
+    color: #7db4ff;
+}
+
+#statsHeader .stats-title {
+    flex: 1 1 auto;
+}
+
+.stats-close {
+    flex: 0 0 auto;
+    cursor: pointer;
+    color: #9aa4b2;
+    margin: 0;
+}
+
+.stats-close:hover {
+    color: #e6e8ec;
+}
+
+#statsBody {
+    flex: 1 1 auto;
+    overflow-y: auto;
+    padding: 0.8em;
+}
+
+.stats-empty {
+    color: #9aa4b2;
+    font-size: 0.88em;
+    padding: 0.6em 0.2em;
+}
+
+.stats-summary {
+    display: flex;
+    gap: 8px;
+    margin-bottom: 0.8em;
+}
+
+.stats-summary-item {
+    flex: 1 1 0;
+    background: #262a33;
+    border-radius: 8px;
+    padding: 10px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 2px;
+}
+
+.stats-summary-item .icon {
+    margin: 0;
+    color: #7db4ff;
+}
+
+.stats-summary-item strong {
+    font-size: 1.05em;
+    font-variant-numeric: tabular-nums;
+}
+
+.stats-summary-item span {
+    font-size: 0.72em;
+    color: #9aa4b2;
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+}
+
+.stats-peer {
+    background: #262a33;
+    border-radius: 8px;
+    padding: 8px 10px;
+    margin-bottom: 6px;
+}
+
+.stats-peer-name {
+    display: flex;
+    align-items: center;
+    gap: 0.5em;
+    font-size: 0.9em;
+    margin-bottom: 4px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.stats-quality {
+    flex: 0 0 auto;
+    font-size: 0.68em;
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+    border-radius: 8px;
+    padding: 1px 8px;
+}
+
+.stats-quality.good {
+    background: #1f4d33;
+    color: #7bed9f;
+}
+
+.stats-quality.fair {
+    background: #5a4a1e;
+    color: #ffe09a;
+}
+
+.stats-quality.poor {
+    background: #5c2b2b;
+    color: #ffb3ab;
+}
+
+.stats-peer-metrics {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 3px 10px;
+    font-size: 0.82em;
+    color: #c3c9d4;
+    font-variant-numeric: tabular-nums;
+}
+
+.stats-peer-metrics span {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.4em;
+}
+
+.stats-peer-metrics .icon {
+    margin: 0;
+    color: #9aa4b2;
+}
+
+/* Invite dialog */
+#inviteModal {
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    background: rgba(0, 0, 0, 0.55);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 40;
+    padding: 1em;
+}
+
+.invite-card {
+    width: 100%;
+    max-width: 420px;
+    background: #1e2128;
+    border: 1px solid #2b2f38;
+    border-radius: 12px;
+    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
+    max-height: calc(100vh - 2em);
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+}
+
+.invite-card-header {
+    flex: 0 0 auto;
+    padding: 0.9em 1.1em;
+    font-weight: bold;
+    border-bottom: 1px solid #2b2f38;
+}
+
+.invite-card-header .icon {
+    color: #7db4ff;
+}
+
+.invite-card-close {
+    float: right;
+    cursor: pointer;
+    color: #9aa4b2;
+}
+
+.invite-card-close:hover {
+    color: #e6e8ec;
+}
+
+.invite-card-body {
+    flex: 1 1 auto;
+    overflow-y: auto;
+    padding: 1.1em;
+}
+
+.invite-field {
+    margin-bottom: 1em;
+}
+
+.invite-field label {
+    display: block;
+    font-size: 0.75em;
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+    color: #9aa4b2;
+    margin-bottom: 0.35em;
+}
+
+.invite-value {
+    color: #e6e8ec;
+    word-break: break-word;
+}
+
+.invite-copy-row {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+}
+
+.invite-id {
+    flex: 1 1 auto;
+    font-size: 1.25em;
+    font-weight: bold;
+    letter-spacing: 0.04em;
+    color: #e6e8ec;
+    font-variant-numeric: tabular-nums;
+}
+
+.invite-copy-row input {
+    flex: 1 1 auto;
+    min-width: 0;
+    background: #262a33;
+    border: 1px solid #3a3f4a;
+    border-radius: 6px;
+    color: #e6e8ec;
+    padding: 8px 10px;
+    font-family: inherit;
+    font-size: 0.85em;
+}
+
+.invite-copy-btn {
+    flex: 0 0 auto;
+}
+
+.invite-note {
+    background: #3a3320;
+    color: #ffe09a;
+    border-radius: 8px;
+    padding: 8px 12px;
+    font-size: 0.82em;
+    margin-bottom: 1em;
+}
+
+.invite-note .icon {
+    margin: 0 0.3em 0 0;
+}
+
+#inviteMessage {
+    width: 100%;
+    background: #262a33;
+    border: 1px solid #3a3f4a;
+    border-radius: 6px;
+    color: #e6e8ec;
+    padding: 8px 10px;
+    font-family: inherit;
+    font-size: 0.9em;
+    resize: vertical;
+    box-sizing: border-box;
+}
+
+.invite-card-footer {
+    flex: 0 0 auto;
+    padding: 0.9em 1.1em;
+    border-top: 1px solid #2b2f38;
+    text-align: right;
+}
+
 /* New chat message popup */
 #msgToast {
     position: absolute;

+ 484 - 16
src/web/MeetRoom/app.js

@@ -40,6 +40,7 @@
         end: "/system/meetroom/end",
         ice: "/system/meetroom/iceservers",
         upload: "/system/meetroom/upload",
+        attachfile: "/system/meetroom/attachfile",
         download: "/system/meetroom/download",
         ws: "/system/meetroom/ws"
     };
@@ -66,9 +67,14 @@
         micOn: true,
         camOn: true,
         sharing: false,
+        handRaised: false,
+        handAt: {}, // peerid -> time (ms) the hand went up, for the raise queue order
         chatOpen: false,
         peopleOpen: false,
+        statsOpen: false,
         unreadChat: 0,
+        clockTimer: null,
+        statsTimer: null,
         leaving: false,
         currentRoomId: "",
         reconnecting: false,
@@ -190,6 +196,13 @@
         ]);
     }
 
+    function playHandSound() {
+        //Gentle single note (A5) to flag a raised hand
+        playChime([
+            { freq: 880.00, at: 0, len: 0.22 }
+        ]);
+    }
+
     /* ================= Lobby actions ================= */
 
     $id("createBtn").addEventListener("click", function () {
@@ -456,11 +469,18 @@
                 break;
             case "peer-leave":
                 removePeer(msg.peerid);
-                addSystemChat(msg.username + " left the meeting");
+                addSystemChat(msg.username + (msg.kicked ? " was removed from the meeting" : " left the meeting"));
                 playLeaveSound();
                 updateParticipantCount();
                 refreshAttendance();
                 break;
+            case "kicked":
+                //The host removed us from the meeting. Stop reconnecting and
+                //fall back to the lobby with a notice.
+                state.leaving = true;
+                cleanupRoom();
+                showLobbyError("You have been removed from the meeting by the host.");
+                break;
             case "signal":
                 handleSignal(msg.from, msg.data);
                 break;
@@ -494,7 +514,7 @@
             stream: new MediaStream(),
             senders: { audio: null, video: null },
             pendingCandidates: [],
-            state: { audio: false, video: false, screen: false }
+            state: { audio: false, video: false, screen: false, hand: false }
         };
         state.peers[info.peerid] = peer;
         addVideoTile(info.peerid, info.username, false);
@@ -620,6 +640,7 @@
             try { peer.pc.close(); } catch (e) { }
         }
         delete state.peers[peerId];
+        delete state.handAt[peerId];
         var tile = $id("tile-" + peerId);
         if (tile) tile.remove();
     }
@@ -635,6 +656,7 @@
             '<video autoplay playsinline ' + (isLocal ? "muted" : "") + '></video>' +
             '<div class="tile-avatar">' + escapeHtml(username.substring(0, 1)) + '</div>' +
             '<div class="sharing-badge"><i class="desktop icon"></i> Sharing screen</div>' +
+            '<div class="hand-badge" title="Hand raised"><i class="hand paper icon"></i></div>' +
             '<div class="tile-label">' +
             '<i class="microphone slash icon muted-icon" style="display:none;"></i>' +
             '<span class="label-name">' + escapeHtml(username) + (isLocal ? " (You)" : "") + '</span>' +
@@ -653,25 +675,79 @@
         if (p && p.catch) p.catch(function () { });
     }
 
-    function setTileState(peerId, audioOn, videoOn, screenOn) {
+    function setTileState(peerId, audioOn, videoOn, screenOn, handUp) {
         var tile = $id("tile-" + peerId);
         if (!tile) return;
         tile.classList.toggle("no-video", !videoOn);
         tile.classList.toggle("is-sharing", !!screenOn);
+        tile.classList.toggle("hand-raised", !!handUp);
         tile.querySelector(".muted-icon").style.display = audioOn ? "none" : "";
     }
 
     function updatePeerState(msg) {
         var peer = state.peers[msg.from];
         if (!peer) return;
-        peer.state = { audio: msg.audio, video: msg.video, screen: msg.screen };
-        setTileState(msg.from, msg.audio, msg.video, msg.screen);
+        var wasHandUp = peer.state.hand;
+        //The first state frame from a peer is an initial sync (peers re-send
+        //their state when anyone joins), so don't treat a hand already up as a
+        //fresh raise - just reflect it on the tile.
+        var firstSync = !peer.stateSynced;
+        peer.stateSynced = true;
+        peer.state = { audio: msg.audio, video: msg.video, screen: msg.screen, hand: !!msg.hand };
+        setTileState(msg.from, msg.audio, msg.video, msg.screen, msg.hand);
+        //Maintain the raise-hand queue order: stamp the first time we see a
+        //hand up, clear it when lowered.
+        if (msg.hand && !wasHandUp) {
+            state.handAt[msg.from] = Date.now();
+        } else if (!msg.hand) {
+            delete state.handAt[msg.from];
+        }
+        if (msg.hand && !wasHandUp && !firstSync) {
+            addSystemChat(peer.info.username + " raised their hand");
+            playHandSound();
+        }
+        if (state.peopleOpen) refreshAttendance();
     }
 
     function updateParticipantCount() {
         $id("participantCount").textContent = String(Object.keys(state.peers).length + 1);
     }
 
+    /* ================= Clock & meeting timer ================= */
+
+    function pad2(n) { return n < 10 ? "0" + n : String(n); }
+
+    //Render a second count as H:MM:SS, dropping the hours until they matter.
+    function formatDuration(totalSeconds) {
+        if (totalSeconds < 0) totalSeconds = 0;
+        var h = Math.floor(totalSeconds / 3600);
+        var m = Math.floor((totalSeconds % 3600) / 60);
+        var s = totalSeconds % 60;
+        return (h > 0 ? h + ":" + pad2(m) : pad2(m)) + ":" + pad2(s);
+    }
+
+    function tickClock() {
+        var now = new Date();
+        $id("systemClock").textContent = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+        if (state.room && state.room.createdat) {
+            var elapsed = Math.floor(Date.now() / 1000) - state.room.createdat;
+            $id("meetingElapsed").textContent = formatDuration(elapsed);
+        }
+    }
+
+    function startClock() {
+        stopClock();
+        tickClock();
+        state.clockTimer = setInterval(tickClock, 1000);
+    }
+
+    function stopClock() {
+        if (state.clockTimer) {
+            clearInterval(state.clockTimer);
+            state.clockTimer = null;
+        }
+    }
+
     /* ================= Room UI ================= */
 
     function showRoomUI(isReconnect) {
@@ -696,6 +772,7 @@
         if (!state.camTrack) $id("camBtn").disabled = true;
         refreshControlButtons();
         refreshLocalTile();
+        startClock();
         if (!isReconnect) {
             addSystemChat("You joined the meeting as " + state.username);
         }
@@ -703,7 +780,7 @@
 
     function refreshLocalTile() {
         var videoOn = (state.camOn && !!state.camTrack) || state.sharing;
-        setTileState("local", state.micOn && !!state.micTrack, videoOn, state.sharing);
+        setTileState("local", state.micOn && !!state.micTrack, videoOn, state.sharing, state.handRaised);
     }
 
     function refreshControlButtons() {
@@ -719,6 +796,11 @@
         var shareBtn = $id("shareBtn");
         shareBtn.classList.toggle("ctrl-active", state.sharing);
         shareBtn.querySelector("span").textContent = state.sharing ? "Stop" : "Share";
+
+        var handBtn = $id("handBtn");
+        handBtn.classList.toggle("ctrl-active", state.handRaised);
+        handBtn.querySelector("i").className = state.handRaised ? "hand paper icon" : "hand paper outline icon";
+        handBtn.querySelector("span").textContent = state.handRaised ? "Lower" : "Raise";
     }
 
     function broadcastState() {
@@ -726,7 +808,8 @@
             type: "state",
             audio: state.micOn && !!state.micTrack,
             video: (state.camOn && !!state.camTrack) || state.sharing,
-            screen: state.sharing
+            screen: state.sharing,
+            hand: state.handRaised
         });
     }
 
@@ -758,6 +841,21 @@
         }
     });
 
+    $id("handBtn").addEventListener("click", function () {
+        state.handRaised = !state.handRaised;
+        if (state.handRaised) {
+            state.handAt[state.myPeerId] = Date.now();
+        } else {
+            delete state.handAt[state.myPeerId];
+        }
+        refreshControlButtons();
+        refreshLocalTile();
+        broadcastState();
+        if (state.peopleOpen) refreshAttendance();
+        addSystemChat(state.handRaised ? "You raised your hand" : "You lowered your hand");
+        if (state.handRaised) playHandSound();
+    });
+
     function replaceOutgoingVideoTrack(track) {
         Object.keys(state.peers).forEach(function (peerId) {
             var peer = state.peers[peerId];
@@ -806,12 +904,74 @@
         broadcastState();
     }
 
-    $id("inviteBtn").addEventListener("click", function () {
-        var text = "Join my ArozOS meeting" +
-            "\nMeeting ID: " + state.room.displayid +
-            (state.room.protected ? "\n(Password required)" : "");
-        copyText(text);
-        addSystemChat("Invite info copied to clipboard");
+    /* ================= Invite dialog ================= */
+
+    //Build a shareable link to this meeting. The lobby reads the room ID from
+    //the URL hash on load (NormalizeRoomID strips the dashes), so the display
+    //ID is fine to embed.
+    function inviteLink() {
+        return location.origin + location.pathname + "#" + state.room.displayid;
+    }
+
+    //Assemble the full invitation text, folding in the host's optional message.
+    function buildInviteText() {
+        var message = $id("inviteMessage").value.trim();
+        var lines = [];
+        if (message !== "") {
+            lines.push(message, "");
+        }
+        lines.push("You're invited to a MeetRoom meeting" + (state.room.title ? ": " + state.room.title : ""));
+        lines.push("Meeting ID: " + state.room.displayid);
+        lines.push("Link: " + inviteLink());
+        if (state.room.protected) {
+            lines.push("(This meeting is password protected - the password will be shared separately.)");
+        }
+        return lines.join("\n");
+    }
+
+    function openInviteModal() {
+        $id("inviteTitle").textContent = state.room.title || "Untitled meeting";
+        $id("inviteId").textContent = state.room.displayid;
+        $id("inviteLink").value = inviteLink();
+        $id("invitePasswordNote").style.display = state.room.protected ? "" : "none";
+        $id("inviteModal").style.display = "flex";
+    }
+
+    function closeInviteModal() {
+        $id("inviteModal").style.display = "none";
+    }
+
+    //Flash a check mark on a copy button so the user sees the copy took
+    function flashCopied(btn) {
+        var icon = btn.querySelector("i");
+        if (!icon || btn.dataset.flashing === "1") return;
+        var original = icon.className;
+        btn.dataset.flashing = "1";
+        icon.className = "check icon";
+        setTimeout(function () {
+            icon.className = original;
+            btn.dataset.flashing = "0";
+        }, 1200);
+    }
+
+    $id("inviteBtn").addEventListener("click", openInviteModal);
+    $id("inviteCloseBtn").addEventListener("click", closeInviteModal);
+    $id("inviteModal").addEventListener("click", function (e) {
+        //Click on the dimmed backdrop (not the card) closes the dialog
+        if (e.target === this) closeInviteModal();
+    });
+
+    Array.prototype.forEach.call(document.querySelectorAll(".invite-copy-btn"), function (btn) {
+        btn.addEventListener("click", function () {
+            copyText(btn.dataset.copy === "link" ? inviteLink() : state.room.displayid);
+            flashCopied(btn);
+        });
+    });
+
+    $id("inviteCopyAllBtn").addEventListener("click", function () {
+        copyText(buildInviteText());
+        flashCopied(this);
+        addSystemChat("Invitation copied to clipboard");
     });
 
     $id("roomIdTag").addEventListener("click", function () {
@@ -872,6 +1032,18 @@
     $id("peopleBtn").addEventListener("click", function () { togglePeople(!state.peopleOpen); });
     $id("peopleCloseBtn").addEventListener("click", function () { togglePeople(false); });
 
+    //Host action: kick a participant. The list is re-rendered often, so the
+    //click is handled by delegation on the stable container.
+    $id("attendanceList").addEventListener("click", function (e) {
+        var btn = e.target.closest ? e.target.closest(".kick-btn") : null;
+        if (!btn) return;
+        var peerId = parseInt(btn.dataset.peerid, 10);
+        var name = btn.dataset.name || "this participant";
+        if (isNaN(peerId)) return;
+        if (!confirm("Remove " + name + " from the meeting?")) return;
+        sendFrame({ type: "kick", to: peerId });
+    });
+
     //Ask the server for the join/leave log; the reply arrives as an
     //"attendance" frame and lands in renderAttendance()
     function refreshAttendance() {
@@ -883,17 +1055,52 @@
         return new Date(unixTime * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
     }
 
+    //Is the participant behind this present attendance record raising a hand?
+    function recordHandRaised(record) {
+        if (!record.present) return false;
+        if (record.peerid === state.myPeerId) return state.handRaised;
+        var peer = state.peers[record.peerid];
+        return !!(peer && peer.state && peer.state.hand);
+    }
+
     function attendanceEntry(record) {
         var entry = document.createElement("div");
         entry.className = "attendance-entry" + (record.present ? " present" : "");
         var times = record.present
             ? "joined " + attendanceTime(record.joinedat)
             : attendanceTime(record.joinedat) + " - " + attendanceTime(record.leftat);
+        var isHostRecord = state.room && record.username === state.room.host;
+        var handUp = recordHandRaised(record);
+        //The host can remove any present participant other than themselves
+        var canKick = state.isHost && record.present && !isHostRecord && record.peerid !== state.myPeerId;
         entry.innerHTML =
             '<i class="' + (record.present ? "user icon" : "sign-out icon") + '"></i>' +
             '<span class="attendee-name">' + escapeHtml(record.username) + '</span>' +
-            (state.room && record.username === state.room.host ? '<span class="host-tag">Host</span>' : "") +
-            '<span class="attendee-times">' + escapeHtml(times) + '</span>';
+            (isHostRecord ? '<span class="host-tag">Host</span>' : "") +
+            (handUp ? '<i class="hand paper icon hand-indicator" title="Hand raised"></i>' : "") +
+            '<span class="attendee-times">' + escapeHtml(times) + '</span>' +
+            (canKick
+                ? '<button class="kick-btn" data-peerid="' + record.peerid + '" data-name="' + escapeAttr(record.username) + '" title="Remove from meeting"><i class="user times icon"></i></button>'
+                : "");
+        return entry;
+    }
+
+    //Build the raise-hand queue: everyone currently present with a hand up,
+    //ordered by when they raised it (earliest first).
+    function raisedHandQueue(present) {
+        return present.filter(recordHandRaised).sort(function (a, b) {
+            return (state.handAt[a.peerid] || 0) - (state.handAt[b.peerid] || 0);
+        });
+    }
+
+    function handQueueEntry(record, position) {
+        var entry = document.createElement("div");
+        entry.className = "hand-queue-entry";
+        var isSelf = record.peerid === state.myPeerId;
+        entry.innerHTML =
+            '<span class="hand-queue-pos">' + position + '</span>' +
+            '<i class="hand paper icon hand-indicator"></i>' +
+            '<span class="attendee-name">' + escapeHtml(record.username) + (isSelf ? " (You)" : "") + '</span>';
         return entry;
     }
 
@@ -903,6 +1110,16 @@
         var present = records.filter(function (r) { return r.present; });
         var past = records.filter(function (r) { return !r.present; });
 
+        //Raised-hand queue first, so the speaking order is front and centre
+        var raised = raisedHandQueue(present);
+        if (raised.length > 0) {
+            var groupHands = document.createElement("div");
+            groupHands.className = "attendance-group";
+            groupHands.textContent = "Raised hands (" + raised.length + ")";
+            box.appendChild(groupHands);
+            raised.forEach(function (record, i) { box.appendChild(handQueueEntry(record, i + 1)); });
+        }
+
         var groupPresent = document.createElement("div");
         groupPresent.className = "attendance-group";
         groupPresent.textContent = "In meeting (" + present.length + ")";
@@ -918,6 +1135,178 @@
         }
     }
 
+    /* ================= Connection performance ================= */
+
+    function toggleStats(open) {
+        state.statsOpen = open;
+        $id("statsBtn").classList.toggle("ctrl-active", open);
+        $id("statsPanel").style.display = open ? "flex" : "none";
+        if (open) {
+            startStatsPolling();
+        } else {
+            stopStatsPolling();
+        }
+    }
+
+    $id("statsBtn").addEventListener("click", function () { toggleStats(!state.statsOpen); });
+    $id("statsCloseBtn").addEventListener("click", function () { toggleStats(false); });
+
+    //Let the user drag the floating stats window around the meeting. Uses
+    //pointer events so it works with both mouse and touch; the header keeps
+    //the pointer capture so dragging continues even if it briefly leaves it.
+    (function makeStatsDraggable() {
+        var panel = $id("statsPanel");
+        var handle = $id("statsHeader");
+        var dragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0;
+
+        handle.addEventListener("pointerdown", function (e) {
+            if (e.target.closest && e.target.closest(".stats-close")) return;
+            var parent = panel.offsetParent || panel.parentElement;
+            var rect = panel.getBoundingClientRect();
+            var pr = parent.getBoundingClientRect();
+            startLeft = rect.left - pr.left;
+            startTop = rect.top - pr.top;
+            startX = e.clientX;
+            startY = e.clientY;
+            //Switch from the CSS right-anchor to explicit left/top so drags move it
+            panel.style.left = startLeft + "px";
+            panel.style.top = startTop + "px";
+            panel.style.right = "auto";
+            dragging = true;
+            try { handle.setPointerCapture(e.pointerId); } catch (err) { }
+            e.preventDefault();
+        });
+        handle.addEventListener("pointermove", function (e) {
+            if (!dragging) return;
+            var parent = panel.offsetParent || panel.parentElement;
+            var maxL = Math.max(0, parent.clientWidth - panel.offsetWidth);
+            var maxT = Math.max(0, parent.clientHeight - panel.offsetHeight);
+            panel.style.left = Math.max(0, Math.min(startLeft + (e.clientX - startX), maxL)) + "px";
+            panel.style.top = Math.max(0, Math.min(startTop + (e.clientY - startY), maxT)) + "px";
+        });
+        var endDrag = function (e) {
+            if (!dragging) return;
+            dragging = false;
+            try { handle.releasePointerCapture(e.pointerId); } catch (err) { }
+        };
+        handle.addEventListener("pointerup", endDrag);
+        handle.addEventListener("pointercancel", endDrag);
+    })();
+
+    function startStatsPolling() {
+        stopStatsPolling();
+        collectAndRenderStats();
+        state.statsTimer = setInterval(collectAndRenderStats, 2000);
+    }
+
+    function stopStatsPolling() {
+        if (state.statsTimer) {
+            clearInterval(state.statsTimer);
+            state.statsTimer = null;
+        }
+    }
+
+    function formatBitrate(kbps) {
+        if (kbps >= 1000) return (kbps / 1000).toFixed(1) + " Mbps";
+        return kbps + " kbps";
+    }
+
+    //Reduce a peer connection's raw getStats() report to the handful of
+    //numbers worth showing, turning cumulative byte counters into a live
+    //bitrate by diffing against the previous sample.
+    function summarizePeerStats(peer, report) {
+        var now = (window.performance && performance.now) ? performance.now() : Date.now();
+        var bytesRecv = 0, bytesSent = 0, packetsRecv = 0, packetsLost = 0, rttMs = null;
+        report.forEach(function (r) {
+            if (r.type === "inbound-rtp" && !r.isRemote) {
+                bytesRecv += r.bytesReceived || 0;
+                packetsRecv += r.packetsReceived || 0;
+                packetsLost += r.packetsLost || 0;
+            } else if (r.type === "outbound-rtp" && !r.isRemote) {
+                bytesSent += r.bytesSent || 0;
+            } else if (r.type === "candidate-pair" && (r.nominated || r.state === "succeeded") &&
+                typeof r.currentRoundTripTime === "number") {
+                rttMs = Math.round(r.currentRoundTripTime * 1000);
+            }
+        });
+
+        var last = peer._lastStats;
+        var recvKbps = 0, sentKbps = 0;
+        if (last && now > last.t) {
+            var dt = (now - last.t) / 1000;
+            recvKbps = Math.max(0, Math.round((bytesRecv - last.bytesRecv) * 8 / 1000 / dt));
+            sentKbps = Math.max(0, Math.round((bytesSent - last.bytesSent) * 8 / 1000 / dt));
+        }
+        peer._lastStats = { t: now, bytesRecv: bytesRecv, bytesSent: bytesSent };
+
+        var totalPackets = packetsRecv + packetsLost;
+        return {
+            username: peer.info.username,
+            recvKbps: recvKbps,
+            sentKbps: sentKbps,
+            rttMs: rttMs,
+            lossPct: totalPackets > 0 ? (packetsLost / totalPackets) * 100 : 0
+        };
+    }
+
+    function collectAndRenderStats() {
+        if (!state.statsOpen) return;
+        var peerIds = Object.keys(state.peers);
+        var promises = peerIds.map(function (peerId) {
+            var peer = state.peers[peerId];
+            if (!peer.pc || !peer.pc.getStats) return Promise.resolve(null);
+            return peer.pc.getStats().then(function (report) {
+                return summarizePeerStats(peer, report);
+            }).catch(function () { return null; });
+        });
+        Promise.all(promises).then(function (results) {
+            renderStats(results.filter(function (r) { return r; }));
+        });
+    }
+
+    //Classify a link by round-trip time and loss so a colour can flag it
+    function linkQuality(s) {
+        if (s.rttMs === null) return "";
+        if (s.rttMs < 150 && s.lossPct < 2) return "good";
+        if (s.rttMs < 300 && s.lossPct < 5) return "fair";
+        return "poor";
+    }
+
+    function renderStats(list) {
+        var box = $id("statsBody");
+        if (!state.connected) {
+            box.innerHTML = '<div class="stats-empty">Not connected.</div>';
+            return;
+        }
+        if (list.length === 0) {
+            box.innerHTML = '<div class="stats-empty">No peer connections yet - stats appear once someone else joins.</div>';
+            return;
+        }
+        var totalRecv = 0, totalSent = 0;
+        list.forEach(function (s) { totalRecv += s.recvKbps; totalSent += s.sentKbps; });
+
+        var html = '<div class="stats-summary">' +
+            '<div class="stats-summary-item"><i class="download icon"></i><strong>' + formatBitrate(totalRecv) + '</strong><span>received</span></div>' +
+            '<div class="stats-summary-item"><i class="upload icon"></i><strong>' + formatBitrate(totalSent) + '</strong><span>sent</span></div>' +
+            '</div>';
+
+        list.forEach(function (s) {
+            var quality = linkQuality(s);
+            html += '<div class="stats-peer">' +
+                '<div class="stats-peer-name">' + escapeHtml(s.username) +
+                (quality ? '<span class="stats-quality ' + quality + '">' + quality + '</span>' : "") +
+                '</div>' +
+                '<div class="stats-peer-metrics">' +
+                '<span title="Receiving from this peer"><i class="download icon"></i>' + formatBitrate(s.recvKbps) + '</span>' +
+                '<span title="Sending to this peer"><i class="upload icon"></i>' + formatBitrate(s.sentKbps) + '</span>' +
+                '<span title="Round-trip time"><i class="stopwatch icon"></i>' + (s.rttMs === null ? "-" : s.rttMs + " ms") + '</span>' +
+                '<span title="Packet loss"><i class="exclamation triangle icon"></i>' + s.lossPct.toFixed(1) + '%</span>' +
+                '</div>' +
+                '</div>';
+        });
+        box.innerHTML = html;
+    }
+
     /* ================= New message popup ================= */
 
     var toastTimer = null;
@@ -1052,10 +1441,77 @@
 
     /* ================= Attachments ================= */
 
-    $id("attachBtn").addEventListener("click", function () {
+    function toggleAttachMenu(open) {
+        $id("attachMenu").style.display = open ? "block" : "none";
+    }
+
+    //The attach button opens a small menu: upload a local file, or pick a
+    //file the user already has in their ArozOS storage.
+    $id("attachBtn").addEventListener("click", function (e) {
+        e.stopPropagation();
+        toggleAttachMenu($id("attachMenu").style.display === "none");
+    });
+
+    //Any click elsewhere dismisses the menu
+    document.addEventListener("click", function () { toggleAttachMenu(false); });
+    $id("attachMenu").addEventListener("click", function (e) { e.stopPropagation(); });
+
+    $id("attachDeviceBtn").addEventListener("click", function () {
+        toggleAttachMenu(false);
         $id("attachInput").click();
     });
 
+    $id("attachArozosBtn").addEventListener("click", function () {
+        toggleAttachMenu(false);
+        if (typeof ao_module_openFileSelector !== "function") {
+            addSystemChat("ArozOS file picker is only available inside the ArozOS desktop");
+            return;
+        }
+        //ao_module_openFileSelector needs a window-scoped callback name; the
+        //selector hands back an array of {filepath, filename}.
+        ao_module_openFileSelector("meetroomArozFilesSelected", "user:/", "file", true);
+    });
+
+    //Called back by the ArozOS file selector (must live on window)
+    window.meetroomArozFilesSelected = function (files) {
+        if (!files || files.length === 0) return;
+        Array.prototype.forEach.call(files, function (f) {
+            attachArozosPath(f.filepath, f.filename);
+        });
+    };
+
+    //Stream a file that already lives in the user's ArozOS storage into the
+    //room without downloading it to the browser first: the server reads it
+    //straight from the user's file system (see /system/meetroom/attachfile).
+    function attachArozosPath(vpath, displayName) {
+        if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
+            addSystemChat("Reconnecting - please try sharing the file again in a moment");
+            return;
+        }
+        var name = displayName || vpath.split("/").pop();
+        var form = new FormData();
+        form.append("roomid", state.room.id);
+        form.append("password", state.password);
+        form.append("path", vpath);
+
+        $id("uploadStatus").style.display = "";
+        $id("uploadStatusText").textContent = "Sharing " + name + "...";
+
+        fetch(API.attachfile, { method: "POST", body: form }).then(function (r) {
+            return r.json();
+        }).then(function (data) {
+            $id("uploadStatus").style.display = "none";
+            if (data.error !== undefined) {
+                addSystemChat("Could not share " + name + ": " + data.error);
+                return;
+            }
+            sendFrame({ type: "file", fileid: data.fileid });
+        }).catch(function () {
+            $id("uploadStatus").style.display = "none";
+            addSystemChat("Could not share " + name);
+        });
+    }
+
     //Upload a file (or pasted image blob) and announce it to the room
     function uploadAndAnnounce(file, displayName) {
         if (!file) return;
@@ -1115,6 +1571,8 @@
 
     function cleanupRoom() {
         stopHeartbeat();
+        stopClock();
+        stopStatsPolling();
         if (state.reconnectTimer) {
             clearTimeout(state.reconnectTimer);
             state.reconnectTimer = null;
@@ -1142,6 +1600,8 @@
         state.screenStream = null;
         state.screenTrack = null;
         state.sharing = false;
+        state.handRaised = false;
+        state.handAt = {};
         state.connected = false;
         state.myPeerId = -1;
         state.room = null;
@@ -1157,6 +1617,14 @@
         hideMessageToast();
         toggleChat(false);
         togglePeople(false);
+        toggleStats(false);
+        //Return the floating stats window to its default corner for next time
+        var sp = $id("statsPanel");
+        sp.style.left = "";
+        sp.style.top = "";
+        sp.style.right = "";
+        closeInviteModal();
+        toggleAttachMenu(false);
         $id("room").style.display = "none";
         $id("lobby").style.display = "flex";
         setWindowTitle("MeetRoom");

+ 81 - 2
src/web/MeetRoom/index.html

@@ -81,7 +81,15 @@
                 </span>
             </div>
             <div class="room-meta">
-                <i class="user icon"></i><span id="participantCount">1</span>
+                <span class="meta-item" title="Meeting duration">
+                    <i class="hourglass half icon"></i><span id="meetingElapsed">00:00</span>
+                </span>
+                <span class="meta-item" title="Current time">
+                    <i class="clock outline icon"></i><span id="systemClock">--:--</span>
+                </span>
+                <span class="meta-item" title="Participants">
+                    <i class="user icon"></i><span id="participantCount">1</span>
+                </span>
             </div>
         </div>
 
@@ -101,6 +109,14 @@
                     <div id="uploadStatus" style="display:none;">
                         <i class="spinner loading icon"></i> <span id="uploadStatusText">Uploading...</span>
                     </div>
+                    <div id="attachMenu" style="display:none;">
+                        <button id="attachDeviceBtn" class="attach-menu-item">
+                            <i class="upload icon"></i> Upload from device
+                        </button>
+                        <button id="attachArozosBtn" class="attach-menu-item">
+                            <i class="folder open outline icon"></i> From ArozOS files
+                        </button>
+                    </div>
                     <div class="ui small action input fluid">
                         <input type="text" id="chatText" maxlength="4000" placeholder="Type a message... (Ctrl+V pastes an image)">
                         <button id="attachBtn" class="ui icon button" title="Share a file">
@@ -122,6 +138,17 @@
             </div>
         </div>
 
+        <!-- Floating, draggable connection-stats window -->
+        <div id="statsPanel" style="display:none;">
+            <div id="statsHeader">
+                <i class="chart line icon"></i><span class="stats-title">Connection stats</span>
+                <i id="statsCloseBtn" class="close icon stats-close" title="Close"></i>
+            </div>
+            <div id="statsBody">
+                <div class="stats-empty">Gathering connection statistics...</div>
+            </div>
+        </div>
+
         <div id="msgToast" style="display:none;" title="Click to open the chat">
             <div class="toast-title"><i class="comment icon"></i><span></span></div>
             <div class="toast-body"></div>
@@ -137,6 +164,9 @@
             <button id="shareBtn" class="ctrl-btn" title="Share your screen">
                 <i class="desktop icon"></i><span>Share</span>
             </button>
+            <button id="handBtn" class="ctrl-btn" title="Raise or lower your hand">
+                <i class="hand paper outline icon"></i><span>Raise</span>
+            </button>
             <button id="chatBtn" class="ctrl-btn" title="Open chat">
                 <i class="comments icon"></i><span>Chat</span>
                 <span id="chatBadge" class="chat-badge" style="display:none;"></span>
@@ -144,7 +174,10 @@
             <button id="peopleBtn" class="ctrl-btn" title="Participants and attendance">
                 <i class="users icon"></i><span>People</span>
             </button>
-            <button id="inviteBtn" class="ctrl-btn" title="Copy invite info">
+            <button id="statsBtn" class="ctrl-btn" title="Connection performance">
+                <i class="chart line icon"></i><span>Stats</span>
+            </button>
+            <button id="inviteBtn" class="ctrl-btn" title="Invite people">
                 <i class="share alternate icon"></i><span>Invite</span>
             </button>
             <button id="leaveBtn" class="ctrl-btn ctrl-danger" title="Leave the meeting">
@@ -154,6 +187,52 @@
                 <i class="stop circle icon"></i><span>End</span>
             </button>
         </div>
+
+        <!-- ================= Invite dialog ================= -->
+        <div id="inviteModal" style="display:none;">
+            <div class="invite-card">
+                <div class="invite-card-header">
+                    <i class="share alternate icon"></i> Invite people
+                    <i id="inviteCloseBtn" class="close icon invite-card-close"></i>
+                </div>
+                <div class="invite-card-body">
+                    <div class="invite-field">
+                        <label>Meeting</label>
+                        <div id="inviteTitle" class="invite-value"></div>
+                    </div>
+                    <div class="invite-field">
+                        <label>Meeting ID</label>
+                        <div class="invite-copy-row">
+                            <span id="inviteId" class="invite-id"></span>
+                            <button class="ui tiny icon button invite-copy-btn" data-copy="id" title="Copy meeting ID">
+                                <i class="copy icon"></i>
+                            </button>
+                        </div>
+                    </div>
+                    <div class="invite-field">
+                        <label>Invite link</label>
+                        <div class="invite-copy-row">
+                            <input id="inviteLink" type="text" readonly>
+                            <button class="ui tiny icon button invite-copy-btn" data-copy="link" title="Copy invite link">
+                                <i class="linkify icon"></i>
+                            </button>
+                        </div>
+                    </div>
+                    <div id="invitePasswordNote" class="invite-note" style="display:none;">
+                        <i class="lock icon"></i> This meeting is password protected - share the password with your guests separately.
+                    </div>
+                    <div class="invite-field">
+                        <label>Message (optional)</label>
+                        <textarea id="inviteMessage" rows="2" maxlength="500" placeholder="Add a personal note to your invitation..."></textarea>
+                    </div>
+                </div>
+                <div class="invite-card-footer">
+                    <button id="inviteCopyAllBtn" class="ui primary button">
+                        <i class="copy icon"></i> Copy invitation
+                    </button>
+                </div>
+            </div>
+        </div>
     </div>
 
     <script src="app.js"></script>

+ 2 - 2
src/web/MeetRoom/init.agi

@@ -8,10 +8,10 @@
 //Setup the module information
 var moduleLaunchInfo = {
 	Name: "MeetRoom",
-	Desc: "Video conferencing with chat, screen sharing and file sharing",
+	Desc: "Video conferencing with chat, screen sharing, raised hands and file sharing",
 	Group: "Internet",
 	IconPath: "MeetRoom/img/module_icon.svg",
-	Version: "1.0.0",
+	Version: "1.1.0",
 	StartDir: "MeetRoom/index.html",
 	SupportFW: true,
 	LaunchFWDir: "MeetRoom/index.html",