Bläddra i källkod

Merge branch 'v3.0.2' of https://github.com/tobychui/arozos into v3.0.2

Toby Chui 3 veckor sedan
förälder
incheckning
401cb647a4

+ 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",