Forráskód Böngészése

Add notification system and sound preferences to Chatspace (#284)

* Chatspace: native desktop notifications + new-message chime

Surface incoming messages beyond the in-app toast that was only visible
while looking at Chatspace:

- Native notifications: DMs and @mentions now raise an OS-level browser
  Notification (the same mechanism the ArozOS desktop uses) when the
  Chatspace window is backgrounded, so users are alerted even when the app
  is behind another window. When Chatspace is embedded in the ArozOS
  virtual desktop, the alert is also mirrored into the desktop notification
  centre. Clicking a notification focuses the app and opens the
  conversation. Permission is requested lazily on the first user gesture.

- New-message chime: while the app is open, every incoming message plays a
  short WebAudio chime - a prominent rising tone for DMs/@mentions and a
  soft single note for ordinary messages - so there is an audible cue for
  new activity. Chimes share a throttle so bursts do not machine-gun the
  speaker.

- Preferences: per-user "Play a sound for new messages" and "Desktop
  notifications" toggles in the profile panel (localStorage-backed), plus a
  hint showing the browser permission state. Both default on.

Everything degrades gracefully: no AudioContext / Notification support or a
standalone PWA launch simply falls back to the in-app toast and title badge.

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

* Chatspace: route push notifications through the ArozOS notification agent

Follow-up to the notification work: instead of raising a browser-only
Notification from the recipient's client, Chatspace now routes DMs and
@mentions through the ArozOS notification system so recipients are reached
even when Chatspace is not open, via each user's own configured agents
(desktop, Telegram, email, webhook).

Server side (AGI sharedspace library):

- Add sharedspace.notifyMembers(spaceid, title, message, [priority],
  [usernames]) - a membership-scoped capability that a regular user can call
  (unlike notification.sendToUser, which is admin only). Only a member may
  notify, only fellow members can be reached, and members currently
  connected to the space's realtime channel are skipped (they already get
  the live message), avoiding duplicate alerts. Delivery per recipient runs
  through the existing NotificationSender, honouring that user's
  notification preferences. Recipient resolution is factored into a pure,
  table-tested helper; an Otto-level integration test exercises the full
  path (membership check, presence skip, explicit target intersection).

Chatspace:

- New backend/notify.js raises the notification for a message's addressees.
- The client calls it when sending a DM or a message that @mentions members
  (or @channel / @everyone), passing the resolved targets.
- Drop the earlier direct browser-Notification path and its per-user
  "desktop notifications" toggle; delivery is now governed by the user's
  ArozOS notification settings. The new-message chime and its sound toggle
  are unchanged.

Docs: document notifyMembers in mod/agi/README.md.

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

* Update .gitignore

* desktop: always show the in-page notification toast, push only when unfocused

Previously an incoming desktop notification was surfaced as *either* an
in-page toast (when the desktop was focused) *or* a browser push (when it
was not). While unfocused - including when focus was in a floatWindow app or
another window - no toast appeared, so a notification would land on the
persistent list (showing the red dot on the clock) with no visible toast,
and the browser push often did nothing (permission not granted / http
origin).

Now the in-page toast is shown regardless of focus, so the notification is
never silently swallowed, and the browser (OS) push is additionally raised
only when the desktop is not focused, to reach the user when they are
looking elsewhere.

deliveryChannelForFocus (single channel) becomes deliveryChannelsForFocus,
returning ["toast"] when focused and ["toast","push"] when not; desktop.html
shows each listed channel independently.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 1 hónapja
szülő
commit
40da213501

+ 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

@@ -1749,6 +1749,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);
             }
         }