Kaynağa Gözat

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

Toby Chui 1 ay önce
ebeveyn
işleme
1236cf9ac0

+ 1 - 0
.gitignore

@@ -43,3 +43,4 @@ src/ffmpeg
 /src/dist
 /apps
 /src/framework
+src/system/telegram_conf.json

+ 25 - 0
src/mod/agi/README.md

@@ -1831,6 +1831,31 @@ requirelib("sharedspace");
 var itemid = sharedspace.addFile(spaceid, "user:/Photo/cat.png");
 ```
 
+### `sharedspace.notifyMembers(spaceid, title, message, priority, usernames)` → number
+
+Raise a notification to fellow members of a space through the ArozOS
+notification system. Membership-scoped: only a member (or space manager) may
+notify, and only current members can be reached, so this cannot be used to
+spam arbitrary users (no admin permission required, unlike
+`notification.sendToUser`). Members currently connected to the space's
+realtime channel are skipped, since they already receive the live message.
+Delivery per recipient follows that user's own notification preferences
+(desktop, Telegram, email, webhook).
+
+`message`, `priority` (`"low"` / `"medium"` / `"high"`, default `"medium"`)
+and `usernames` are optional. When `usernames` (an array) is omitted every
+other member is notified; when given, the recipients are narrowed to that set
+intersected with the current members. Returns the number of members actually
+notified, or `-1` on error.
+
+```javascript
+requirelib("sharedspace");
+//Notify everyone else in the space
+sharedspace.notifyMembers(spaceid, "New message", "Alice: are we still on?");
+//Notify just two members, at high priority
+sharedspace.notifyMembers(spaceid, "Alice in #plan", "@bob @carol ping", "high", ["bob", "carol"]);
+```
+
 ### `sharedspace.listItems(spaceid)` → array | null
 
 Chronological list of items:

+ 108 - 0
src/mod/agi/agi.sharedspace.go

@@ -2,9 +2,11 @@ package agi
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
+	"sort"
 
 	"github.com/robertkrimen/otto"
 	"imuslab.com/arozos/mod/agi/static"
@@ -89,6 +91,38 @@ func agiDescribeDoc(doc *sharedspace.DocSnapshot, includeContent bool) map[strin
 	return desc
 }
 
+// resolveNotifyRecipients computes who should receive a space notification.
+// It starts from the space members, optionally narrows to an explicit target
+// set (nil = every member), and always drops the sender and anyone currently
+// connected to the space (they are already receiving the live message). The
+// result is sorted for deterministic delivery and testing.
+func resolveNotifyRecipients(members map[string]string, present map[string]bool, sender string, requested []string) []string {
+	var requestedSet map[string]bool
+	if requested != nil {
+		requestedSet = make(map[string]bool, len(requested))
+		for _, name := range requested {
+			if name != "" {
+				requestedSet[name] = true
+			}
+		}
+	}
+	recipients := []string{}
+	for username := range members {
+		if username == "" || username == sender {
+			continue
+		}
+		if present != nil && present[username] {
+			continue
+		}
+		if requestedSet != nil && !requestedSet[username] {
+			continue
+		}
+		recipients = append(recipients, username)
+	}
+	sort.Strings(recipients)
+	return recipients
+}
+
 func (g *Gateway) injectSharedSpaceFunctions(payload *static.AgiLibInjectionPayload) {
 	vm := payload.VM
 	u := payload.User
@@ -98,6 +132,13 @@ func (g *Gateway) injectSharedSpaceFunctions(payload *static.AgiLibInjectionPayl
 		return
 	}
 
+	//The sender label shown to the user is the script's module root (e.g.
+	//"Chatspace"), matching the AGI notification library's convention.
+	senderLabel := "Shared Space"
+	if payload.ScriptPath != "" {
+		senderLabel = static.GetScriptRoot(payload.ScriptPath, "./web/")
+	}
+
 	jsonReply := func(v interface{}) otto.Value {
 		js, err := json.Marshal(v)
 		if err != nil {
@@ -359,6 +400,67 @@ func (g *Gateway) injectSharedSpaceFunctions(payload *static.AgiLibInjectionPayl
 		return val
 	})
 
+	//Raise a notification to fellow members of a space through the ArozOS
+	//notification system. Membership-scoped: only a member (or manager) may
+	//notify, and only current members can be reached, so this cannot spam
+	//arbitrary users. Members currently connected to the space's realtime
+	//channel are skipped (they already receive the live message). Delivery
+	//per recipient follows that user's own notification preferences (desktop,
+	//Telegram, email, webhook). Returns the number of members notified, or -1
+	//on error.
+	//  args: (spaceid, title, [message], [priority], [targetsJSON])
+	vm.Set("_sharedspace_notifyMembers", func(call otto.FunctionCall) otto.Value {
+		intReply := func(n int) otto.Value { v, _ := vm.ToValue(n); return v }
+
+		space, ok := getSpace(call)
+		if !ok {
+			g.RaiseError(errors.New("space not found"))
+			return intReply(-1)
+		}
+		if _, isMember := space.Role(u.Username); !isMember && !space.CanManage(u.Username) {
+			g.RaiseError(errors.New("permission denied: not a member of this space"))
+			return intReply(-1)
+		}
+
+		title, err := call.Argument(1).ToString()
+		if err != nil || title == "undefined" || title == "" {
+			g.RaiseError(errors.New("notification title cannot be empty"))
+			return intReply(-1)
+		}
+		message := optionalString(call, 2)
+		priority := optionalString(call, 3)
+		if priority == "" {
+			priority = "medium"
+		}
+
+		//An explicit target list (JSON array) narrows the recipients to those
+		//members; omitting it notifies every other member. A malformed list
+		//fails safe to "notify nobody".
+		var requested []string
+		if targetsJSON := optionalString(call, 4); targetsJSON != "" {
+			if jerr := json.Unmarshal([]byte(targetsJSON), &requested); jerr != nil {
+				requested = []string{}
+			}
+		}
+
+		present := map[string]bool{}
+		for _, sub := range space.Channel().Subscribers() {
+			present[sub.Username] = true
+		}
+
+		recipients := resolveNotifyRecipients(space.Members(), present, u.Username, requested)
+		if len(recipients) == 0 {
+			//Nobody to notify (everyone is present, or none matched).
+			return intReply(0)
+		}
+
+		if err := g.buildAndSendNotification(senderLabel, recipients, title, message, priority); err != nil {
+			g.RaiseError(err)
+			return intReply(-1)
+		}
+		return intReply(len(recipients))
+	})
+
 	//Share a file from the calling user's storage into a space
 	vm.Set("_sharedspace_addFile", func(call otto.FunctionCall) otto.Value {
 		space, ok := getSpace(call)
@@ -606,6 +708,12 @@ func (g *Gateway) injectSharedSpaceFunctions(payload *static.AgiLibInjectionPayl
 		sharedspace.removeMember = _sharedspace_removeMember;
 		sharedspace.listMembers = function(spaceid){ var r = _sharedspace_listMembers(spaceid); return r === null ? null : JSON.parse(r); };
 		sharedspace.addText = _sharedspace_addText;
+		sharedspace.notifyMembers = function(spaceid, title, message, priority, usernames){
+			return _sharedspace_notifyMembers(spaceid, title,
+				message === undefined ? "" : message,
+				priority === undefined ? "" : priority,
+				(usernames === undefined || usernames === null) ? "" : JSON.stringify(usernames));
+		};
 		sharedspace.addFile = _sharedspace_addFile;
 		sharedspace.listItems = function(spaceid){ var r = _sharedspace_listItems(spaceid); return r === null ? null : JSON.parse(r); };
 		sharedspace.getText = _sharedspace_getText;

+ 154 - 0
src/mod/agi/agi.sharedspace_test.go

@@ -2,11 +2,165 @@ package agi
 
 import (
 	"path/filepath"
+	"reflect"
+	"sort"
 	"testing"
 
+	"github.com/robertkrimen/otto"
+	"imuslab.com/arozos/mod/agi/static"
+	notification "imuslab.com/arozos/mod/notification"
 	"imuslab.com/arozos/mod/sharedspace"
+	user "imuslab.com/arozos/mod/user"
 )
 
+func TestResolveNotifyRecipients(t *testing.T) {
+	members := map[string]string{
+		"alice": "owner", "bob": "member", "carol": "member", "dave": "member",
+	}
+	tests := []struct {
+		name      string
+		present   map[string]bool
+		sender    string
+		requested []string
+		want      []string
+	}{
+		{
+			name:   "all members except the sender",
+			sender: "alice",
+			want:   []string{"bob", "carol", "dave"},
+		},
+		{
+			name:    "members present in the space are skipped",
+			sender:  "alice",
+			present: map[string]bool{"bob": true},
+			want:    []string{"carol", "dave"},
+		},
+		{
+			name:      "an explicit list narrows the recipients",
+			sender:    "alice",
+			requested: []string{"bob", "dave"},
+			want:      []string{"bob", "dave"},
+		},
+		{
+			name:      "explicit targets are intersected with members",
+			sender:    "alice",
+			requested: []string{"bob", "stranger"},
+			want:      []string{"bob"},
+		},
+		{
+			name:      "sender and present users are dropped from an explicit list",
+			sender:    "alice",
+			present:   map[string]bool{"dave": true},
+			requested: []string{"alice", "bob", "dave"},
+			want:      []string{"bob"},
+		},
+		{
+			name:      "an empty explicit list notifies nobody",
+			sender:    "alice",
+			requested: []string{},
+			want:      []string{},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := resolveNotifyRecipients(members, tt.present, tt.sender, tt.requested)
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("resolveNotifyRecipients() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+// TestNotifyMembersIntegration drives the injected sharedspace.notifyMembers
+// JS function through a real Otto VM against a live space, exercising the full
+// path (argument parsing, membership check, presence skip, delivery).
+func TestNotifyMembersIntegration(t *testing.T) {
+	sm := sharedspace.NewManager(filepath.Join(t.TempDir(), "spaces"), 0)
+	space, err := sm.CreateSpaceWithOptions("alice", "team", sharedspace.SpaceOptions{
+		Access: sharedspace.AccessPrivate,
+	})
+	if err != nil {
+		t.Fatalf("CreateSpaceWithOptions() error = %v", err)
+	}
+	space.AddMember("alice", "bob", sharedspace.RoleMember)
+	space.AddMember("alice", "carol", sharedspace.RoleMember)
+
+	var captured []*notification.NotificationPayload
+	g := &Gateway{Option: &AgiSysInfo{
+		SharedSpaceManager: sm,
+		NotificationSender: func(p *notification.NotificationPayload) error {
+			captured = append(captured, p)
+			return nil
+		},
+	}}
+
+	run := func(username, script string) otto.Value {
+		vm := otto.New()
+		g.injectSharedSpaceFunctions(&static.AgiLibInjectionPayload{
+			VM:         vm,
+			User:       &user.User{Username: username},
+			ScriptPath: "./web/Chatspace/backend/notify.js",
+		})
+		v, runErr := vm.Run(script)
+		if runErr != nil {
+			t.Fatalf("vm.Run(%q) error = %v", script, runErr)
+		}
+		return v
+	}
+
+	// A member notifies every other member; the sender label is the module root.
+	captured = nil
+	v := run("alice", `sharedspace.notifyMembers("`+space.ID+`", "alice", "hi team")`)
+	if n, _ := v.ToInteger(); n != 2 {
+		t.Fatalf("notified = %d, want 2", n)
+	}
+	if len(captured) != 1 {
+		t.Fatalf("sender called %d times, want 1", len(captured))
+	}
+	got := append([]string{}, captured[0].Receiver...)
+	sort.Strings(got)
+	if !reflect.DeepEqual(got, []string{"bob", "carol"}) {
+		t.Errorf("receivers = %v, want [bob carol]", got)
+	}
+	if captured[0].Sender != "Chatspace" {
+		t.Errorf("sender label = %q, want Chatspace", captured[0].Sender)
+	}
+	if captured[0].Message != "hi team" {
+		t.Errorf("message = %q, want 'hi team'", captured[0].Message)
+	}
+
+	// Members currently connected to the space are skipped (they get it live).
+	captured = nil
+	space.Channel().Join("carol")
+	v = run("alice", `sharedspace.notifyMembers("`+space.ID+`", "alice", "hi")`)
+	if n, _ := v.ToInteger(); n != 1 {
+		t.Fatalf("notified = %d, want 1 (carol is present)", n)
+	}
+	if len(captured) != 1 || len(captured[0].Receiver) != 1 || captured[0].Receiver[0] != "bob" {
+		t.Errorf("receivers = %v, want [bob]", captured[0].Receiver)
+	}
+
+	// An explicit target list is intersected with the members.
+	captured = nil
+	v = run("alice", `sharedspace.notifyMembers("`+space.ID+`", "alice", "hi", "high", ["bob","stranger"])`)
+	if n, _ := v.ToInteger(); n != 1 {
+		t.Fatalf("notified = %d, want 1 (only bob is a member)", n)
+	}
+	if captured[0].Priority != notification.PriorityHigh {
+		t.Errorf("priority = %d, want high", captured[0].Priority)
+	}
+
+	// A non-member cannot raise notifications for the space.
+	captured = nil
+	v = run("dave", `sharedspace.notifyMembers("`+space.ID+`", "dave", "hi")`)
+	if n, _ := v.ToInteger(); n != -1 {
+		t.Errorf("non-member notified = %d, want -1", n)
+	}
+	if len(captured) != 0 {
+		t.Errorf("non-member must not send; captured %d notifications", len(captured))
+	}
+}
+
 func TestAgiDescribeSpaceAdvancedFields(t *testing.T) {
 	sm := sharedspace.NewManager(filepath.Join(t.TempDir(), "spaces"), 0)
 	space, err := sm.CreateSpaceWithOptions("alice", "Project room", sharedspace.SpaceOptions{

+ 169 - 29
src/web/Chatspace/app.js

@@ -44,6 +44,7 @@
         createChannel: "../system/ajgi/interface?script=Chatspace/backend/createChannel.js",
         openDm: "../system/ajgi/interface?script=Chatspace/backend/openDm.js",
         aibot: "../system/ajgi/interface?script=Chatspace/backend/aibot.js",
+        notify: "../system/ajgi/interface?script=Chatspace/backend/notify.js",
         saveToArozOS: "../system/ajgi/interface?script=Chatspace/backend/saveToArozOS.js",
         info: "../system/sharedspace/info",
         join: "../system/sharedspace/join",
@@ -148,7 +149,9 @@
         booted: false,
         prefs: {
             lastRead: {}, starred: [], saved: [], drafts: {},
-            collapsed: {}, lastActive: "", activitySeen: 0
+            collapsed: {}, lastActive: "", activitySeen: 0,
+            //Play an audible chime for incoming messages while the app is open.
+            sound: true
         }
     };
 
@@ -297,42 +300,141 @@
             ';font-size:' + (fontPx || 14) + 'px;">' + escapeHtml(initial) + '</div>';
     }
 
-    /* ================= Sound (WebAudio chime, no bundled assets) ================= */
+    /* ================= Sound (WebAudio chime, no bundled assets) =================
+
+       Two chimes, both synthesised on the fly (no bundled audio asset): a
+       prominent rising two-note chime for DMs / @mentions and a soft single
+       note for ordinary new messages, so the app gives an audible cue for
+       every incoming message while it is open. Both honour the per-user
+       "sound" preference and share a short throttle so a burst of messages
+       cannot machine-gun the speaker. */
 
     var audioCtx = null;
     var lastChime = 0;
 
-    function playNotifySound() {
+    //Create (once) and resume the shared AudioContext. Resume only takes
+    //effect during / after a user gesture, so unlockMedia() warms it up on the
+    //first interaction; until then the browser keeps it suspended and silent.
+    function ensureAudioCtx() {
+        if (!audioCtx) {
+            var Ctx = window.AudioContext || window.webkitAudioContext;
+            if (!Ctx) return null;
+            audioCtx = new Ctx();
+        }
+        if (audioCtx.state === "suspended") {
+            var p = audioCtx.resume();
+            if (p && p.catch) p.catch(function () { });
+        }
+        return audioCtx;
+    }
+
+    //Play a chime built from a list of {freq, at, len, vol} sine-wave notes.
+    function playChime(notes) {
+        if (!state.prefs.sound) return;
         var now = Date.now();
-        if (now - lastChime < 3000) return;
+        if (now - lastChime < 1500) return;
         lastChime = now;
         try {
-            if (!audioCtx) {
-                var Ctx = window.AudioContext || window.webkitAudioContext;
-                if (!Ctx) return;
-                audioCtx = new Ctx();
-            }
-            if (audioCtx.state === "suspended") {
-                var p = audioCtx.resume();
-                if (p && p.catch) p.catch(function () { });
-            }
-            var t = audioCtx.currentTime;
-            [{ freq: 830, at: 0, len: 0.09 }, { freq: 1245, at: 0.09, len: 0.16 }].forEach(function (note) {
-                var osc = audioCtx.createOscillator();
-                var gain = audioCtx.createGain();
+            var ctx = ensureAudioCtx();
+            if (!ctx) return;
+            var t = ctx.currentTime;
+            notes.forEach(function (note) {
+                var osc = ctx.createOscillator();
+                var gain = ctx.createGain();
                 osc.type = "sine";
                 osc.frequency.value = note.freq;
                 gain.gain.setValueAtTime(0.0001, t + note.at);
-                gain.gain.linearRampToValueAtTime(0.08, t + note.at + 0.02);
+                gain.gain.linearRampToValueAtTime(note.vol || 0.08, t + note.at + 0.02);
                 gain.gain.exponentialRampToValueAtTime(0.0001, t + note.at + note.len);
                 osc.connect(gain);
-                gain.connect(audioCtx.destination);
+                gain.connect(ctx.destination);
                 osc.start(t + note.at);
                 osc.stop(t + note.at + note.len + 0.05);
             });
         } catch (e) { }
     }
 
+    //Prominent rising chime for DMs and @mentions.
+    function playNotifySound() {
+        playChime([{ freq: 830, at: 0, len: 0.09 }, { freq: 1245, at: 0.09, len: 0.16 }]);
+    }
+
+    //Soft single note for ordinary new channel messages.
+    function playMessageChime() {
+        playChime([{ freq: 660, at: 0, len: 0.12, vol: 0.05 }]);
+    }
+
+    //Unlock audio on the first user gesture (browsers keep the AudioContext
+    //suspended until then). Runs once, then detaches itself.
+    var mediaUnlocked = false;
+    function unlockMedia() {
+        if (mediaUnlocked) return;
+        mediaUnlocked = true;
+        ensureAudioCtx();
+        document.removeEventListener("click", unlockMedia);
+        document.removeEventListener("keydown", unlockMedia);
+        document.removeEventListener("touchstart", unlockMedia);
+    }
+
+    /* ================= Native notifications (ArozOS notification agent) =======
+
+       Push notifications are routed through the ArozOS notification system
+       rather than a browser-only alert: when a message needs to reach people
+       (a DM, or an @mention / @channel / @everyone in a channel), the sender
+       asks the backend to raise a notification for the target members. The
+       core delivers it to each recipient through *their own* configured
+       notification agents - the ArozOS desktop (which itself raises the OS
+       push when unfocused), Telegram, email or a webhook - so users are
+       reached even when Chatspace is closed.
+
+       The backend skips members currently connected to the conversation, so
+       anyone with it open just gets the live message (and the chime) instead
+       of a duplicate notification. See backend/notify.js and the sharedspace
+       AGI library's notifyMembers(). */
+
+    //Resolve who a message should notify: for a DM, the other participants;
+    //for a channel, the members it @mentions (everyone on @channel/@everyone).
+    function notifyRecipients(convo, text) {
+        if (convo.kind === "dm") return dmOthers(convo);
+        return channelMentionTargets(convo, text);
+    }
+
+    function channelMentionTargets(convo, text) {
+        var members = Object.keys(convo.members || {});
+        //Broadcast mentions ping every other member of the channel.
+        if (/(^|[\s(>])@(everyone|channel)(?![A-Za-z0-9_.\-])/.test(text)) {
+            return members.filter(function (u) { return u !== state.username; });
+        }
+        //Otherwise collect the individually @mentioned members.
+        var targets = [];
+        var re = /(^|[\s(>])@([A-Za-z0-9_.\-]+)/g;
+        var match;
+        while ((match = re.exec(text)) !== null) {
+            var name = match[2];
+            if (name === state.username || name.toLowerCase() === AI_HANDLE) continue;
+            if (members.indexOf(name) >= 0 && targets.indexOf(name) < 0) targets.push(name);
+        }
+        return targets;
+    }
+
+    //Ask the backend to raise an ArozOS notification for the members a message
+    //addresses. Best effort: failures are swallowed (the message is already
+    //delivered over the realtime channel regardless).
+    function pushMemberNotification(convo, text) {
+        if (!text) return;
+        var targets = notifyRecipients(convo, text);
+        if (!targets || targets.length === 0) return;
+        var heading = convo.kind === "dm"
+            ? state.username
+            : state.username + " in #" + convoLabel(convo);
+        $.post(API.notify, {
+            spaceid: convo.id,
+            title: heading,
+            message: text,
+            targets: JSON.stringify(targets)
+        }, function () { }, "json");
+    }
+
     /* ================= Toasts ================= */
 
     function showToast(title, body, onClick) {
@@ -696,6 +798,10 @@
         }
         sendEnvelope(convo, env);
         markRead(convo);
+        //Route a push notification to the members this message addresses
+        //(DM recipients, or @mentioned members) via the ArozOS notification
+        //agent, so they are reached even without Chatspace open.
+        pushMemberNotification(convo, text);
         if (mentionsAi(text)) triggerAiBot(convo, text, threadId || "");
     }
 
@@ -917,16 +1023,24 @@
         if (isMessage && !mine) {
             var text = env ? String(env.t || "") : (item.type === "text" ? item.text : "Shared " + item.name);
             var sender = isBot ? AI_DISPLAY : item.uploader;
-            var notify = false;
-            if (convo.kind === "dm") notify = true;
-            if (mentionsMe(text)) notify = true;
-            if (notify && (!state.focused || state.active !== convo.id)) {
-                playNotifySound();
-                showToast(
-                    convo.kind === "dm" ? sender : sender + " in #" + convoLabel(convo),
-                    text || item.name || "",
-                    function () { setActive(convo.id); }
-                );
+            var notify = (convo.kind === "dm") || mentionsMe(text);
+            //Away = the user is not reading this conversation right now
+            //(another convo open, or the window is in the background).
+            var away = !state.focused || state.active !== convo.id;
+
+            //Audible cue for every incoming message while the app is open: a
+            //prominent chime for DMs / @mentions, a soft one otherwise.
+            if (notify) playNotifySound();
+            else playMessageChime();
+
+            //Surface DMs / @mentions we are not currently reading with an
+            //in-app toast. (Reaching users who do not have Chatspace open is
+            //handled server-side by the sender via the ArozOS notification
+            //agent - see pushMemberNotification / backend/notify.js.)
+            if (notify && away) {
+                var heading = convo.kind === "dm" ? sender : sender + " in #" + convoLabel(convo);
+                var preview = text || item.name || "";
+                showToast(heading, preview, function () { setActive(convo.id); });
             }
         }
         delete convo.typing[item.uploader];
@@ -2515,9 +2629,29 @@
             html += '<div class="rp-section"><h4>Groups</h4><div class="rp-value">' +
                 escapeHtml(groups.join(", ")) + '</div></div>';
         }
+        //Your own profile doubles as the notification preferences panel.
+        if (username === state.username) {
+            html += '<div class="rp-section"><h4>Notifications</h4>' +
+                '<div class="cs-toggle-row"><span>Play a sound for new messages</span>' +
+                '<label class="cs-switch"><input type="checkbox" id="prefSound"' +
+                (state.prefs.sound ? ' checked' : '') + '><span class="cs-slider"></span></label></div>' +
+                '<div class="cs-field-hint">DMs and mentions are delivered through your ArozOS ' +
+                'notifications (desktop, Telegram, email...) when you are away. ' +
+                'Choose how you receive them in System Settings &rsaquo; Notifications.</div>' +
+                '</div>';
+        }
         $id("rpBody").innerHTML = html;
         var dmBtn = $id("rpDmBtn");
         if (dmBtn) dmBtn.addEventListener("click", function () { openDmWith([username]); });
+
+        var soundToggle = $id("prefSound");
+        if (soundToggle) {
+            soundToggle.addEventListener("change", function () {
+                state.prefs.sound = this.checked;
+                savePrefs();
+                if (this.checked) playNotifySound(); //audible confirmation
+            });
+        }
     }
 
     /* ================= Channel management actions ================= */
@@ -3845,6 +3979,12 @@
         });
         window.addEventListener("blur", function () { state.focused = false; });
 
+        //Browsers gate audio playback and the notification-permission prompt
+        //behind a user gesture, so unlock both on the first interaction.
+        document.addEventListener("click", unlockMedia);
+        document.addEventListener("keydown", unlockMedia);
+        document.addEventListener("touchstart", unlockMedia);
+
         //Drag & drop / paste uploads
         var content = $id("content");
         content.addEventListener("dragover", function (e) { e.preventDefault(); });

+ 75 - 0
src/web/Chatspace/backend/notify.js

@@ -0,0 +1,75 @@
+/*
+    Chatspace - route a message notification to co-members through the
+    ArozOS notification system (AGI).
+
+    POST parameters (injected as VM globals by the AGI gateway):
+      spaceid  - the conversation (shared space) the message was posted in
+      title    - notification title (e.g. the sender, or "sender in #channel")
+      message  - the message preview text (optional)
+      targets  - optional JSON array (or comma separated list) of usernames to
+                 notify; when omitted every other member is notified
+      priority - optional "low" | "medium" | "high" (default "medium")
+
+    Only a member of the space may raise a notification, only fellow members
+    can be reached, and members currently connected to the conversation are
+    skipped (they already receive the message live). How each recipient is
+    reached - desktop, Telegram, email, webhook - follows that user's own
+    ArozOS notification preferences.
+
+    Response: {"ok": true, "notified": N} or {"error": "..."}.
+*/
+
+requirelib("sharedspace");
+
+function fail(reason) {
+    sendJSONResp(JSON.stringify({ error: reason }));
+}
+
+function parseTargets(raw) {
+    var value = String(raw);
+    if (value === "") {
+        return undefined;
+    }
+    //Prefer a JSON array; fall back to a comma separated list for convenience.
+    try {
+        var parsed = JSON.parse(value);
+        if (Object.prototype.toString.call(parsed) === "[object Array]") {
+            return parsed;
+        }
+    } catch (e) {
+        //Not JSON - treat as comma separated below.
+    }
+    var list = [];
+    var pieces = value.split(",");
+    for (var i = 0; i < pieces.length; i++) {
+        var name = pieces[i].replace(/^\s+|\s+$/g, "");
+        if (name !== "") {
+            list.push(name);
+        }
+    }
+    return list;
+}
+
+function main() {
+    if (typeof spaceid === "undefined" || String(spaceid) === "") {
+        fail("Missing spaceid");
+        return;
+    }
+    if (typeof title === "undefined" || String(title) === "") {
+        fail("Missing notification title");
+        return;
+    }
+
+    var body = (typeof message === "undefined") ? "" : String(message);
+    var prio = (typeof priority === "undefined" || String(priority) === "") ? "medium" : String(priority);
+    var targetList = (typeof targets === "undefined") ? undefined : parseTargets(targets);
+
+    var notified = sharedspace.notifyMembers(String(spaceid), String(title), body, prio, targetList);
+    if (notified < 0) {
+        fail("Could not send notification");
+        return;
+    }
+    sendJSONResp(JSON.stringify({ ok: true, notified: notified }));
+}
+
+main();

+ 6 - 0
src/web/Chatspace/index.html

@@ -331,6 +331,12 @@
                 <p><b>Formatting:</b> *bold*, _italic_, ~strike~, `code`,
                     ```code block```, &gt; quote, - bulleted lists, :thumbsup: icon
                     shortcodes and @username mentions.</p>
+                <p><b>Notifications:</b> new messages chime while Chatspace is
+                    open (toggle the sound from your profile - click your
+                    avatar). DMs and @mentions are also delivered through the
+                    ArozOS notification system - desktop, Telegram, email or a
+                    webhook, per your System Settings - so they reach you even
+                    when Chatspace is closed.</p>
                 <p><b>Shortcuts:</b> Ctrl+K jump to a conversation, Enter to send,
                     Shift+Enter for a new line, Up arrow to edit your last message
                     (works in threads too), Esc to close a menu or the thread panel.

+ 8 - 6
src/web/SystemAO/notification/notification.js

@@ -132,11 +132,13 @@
         return priorityToInt(notificationPriority) >= priorityToInt(minPriority);
     }
 
-    // deliveryChannelForFocus picks how an incoming desktop notification is
-    // surfaced: an in-page "toast" when the desktop tab is focused, or a
-    // browser (Chrome) "push" when it is not (hidden tab / unfocused window).
-    function deliveryChannelForFocus(isFocused) {
-        return isFocused ? "toast" : "push";
+    // deliveryChannelsForFocus lists how an incoming desktop notification should
+    // be surfaced. The in-page "toast" is always included so the notification is
+    // shown whether or not the desktop is focused; a browser (Chrome) "push" is
+    // added only when the desktop is not focused (hidden tab / unfocused
+    // window), to reach the user when they are looking elsewhere.
+    function deliveryChannelsForFocus(isFocused) {
+        return isFocused ? ["toast"] : ["toast", "push"];
     }
 
     // toastPriorityClass returns the CSS modifier class for a toast of the
@@ -166,7 +168,7 @@
         browserPermissionState: browserPermissionState,
         dedupeNotifications: dedupeNotifications,
         shouldShowBrowserPush: shouldShowBrowserPush,
-        deliveryChannelForFocus: deliveryChannelForFocus,
+        deliveryChannelsForFocus: deliveryChannelsForFocus,
         toastPriorityClass: toastPriorityClass,
         toastDurationMs: toastDurationMs
     };

+ 11 - 9
src/web/desktop.html

@@ -7026,16 +7026,18 @@
             //Always add it to the persistent notification list.
             sendNotification(title, message, arozNotificationIcon(item.priority), null);
 
-            //Decide how to surface it: toast when focused, browser push when not.
-            var channel = (typeof NotificationUI !== "undefined")
-                ? NotificationUI.deliveryChannelForFocus(pageIsFocused())
-                : (pageIsFocused() ? "toast" : "push");
-
-            if (channel === "toast"){
-                //Foreground: show an in-page toast.
+            //Surface it: the in-page toast is shown whether or not the desktop
+            //is focused, so the notification is never silently swallowed; a
+            //browser (OS) push is additionally raised only when the desktop is
+            //not focused, to reach the user when they are looking elsewhere.
+            var channels = (typeof NotificationUI !== "undefined")
+                ? NotificationUI.deliveryChannelsForFocus(pageIsFocused())
+                : (pageIsFocused() ? ["toast"] : ["toast", "push"]);
+
+            if (channels.indexOf("toast") >= 0){
                 showNotificationToast(title, message, item.priority);
-            } else {
-                //Backgrounded / unfocused: raise a browser (Chrome) push instead.
+            }
+            if (channels.indexOf("push") >= 0){
                 showBrowserPush(title, message, item.priority);
             }
         }