From 9c76b4e751b42098dccaf58040ec3fe999d376af Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Tue, 19 May 2026 13:14:47 +0200 Subject: [PATCH 01/83] feat(notifications): add mention consent and OneSignal role scaffolding Add a dedicated mention-notification consent setting to Notification Centre, persist it to OneSignal tags, and localize the new control across all supported locales. Also introduce OneSignal expert role definitions and a ready-to-implement chat/notification specification for Discord-parity mention flows. Co-authored-by: Cursor --- .agents/AGENTS.md | 2 + .agents/roles/senior-onesignal-expert.base.md | 9 + ...r-onesignal-notifications-engineer.base.md | 136 +++++++++++++ .../requirements.md | 185 ++++++++++++++++++ .../components/notification-centre-form.tsx | 46 +++++ .../src/notifications/hooks/validation.ts | 1 + packages/i18n/src/messages/de.json | 1 + packages/i18n/src/messages/en.json | 1 + packages/i18n/src/messages/es.json | 1 + packages/i18n/src/messages/fr.json | 1 + packages/i18n/src/messages/pt.json | 1 + packages/notifications/src/constants/tags.ts | 8 +- .../src/hooks/use-notifications.ts | 12 ++ 13 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 .agents/roles/senior-onesignal-expert.base.md create mode 100644 .agents/roles/senior-onesignal-notifications-engineer.base.md create mode 100644 docs/requirements/Features/chat-mentions-notifications-discord-parity/requirements.md diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 23e6b35af2..eaaf9c7c01 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -13,6 +13,8 @@ When the user has not specified a role, select the best-matching one based on th | **Meta-Cognitive Reasoning Expert** | [roles/meta-cognitive-reasoning-expert.base.md](roles/meta-cognitive-reasoning-expert.base.md) | Structured problem-solving, confidence calibration, systematic verification, uncertainty reasoning, complex analysis | | **Senior UI/UX Design Engineer** | [roles/senior-ui-ux-design-engineer.base.md](roles/senior-ui-ux-design-engineer.base.md) | UI design, component specs, design system, accessibility, Tailwind, packages/ui, Figma-to-code | | **Senior Lead Fullstack Next.js Engineer** | [roles/senior-lead-fullstack-nextjs-engineer.base.md](roles/senior-lead-fullstack-nextjs-engineer.base.md) | Next.js architecture, App Router, React Server Components, server actions, route handlers, fullstack TypeScript, API design, caching, revalidation, performance, delivery leadership | +| **Senior OneSignal Notifications Engineer** | [roles/senior-onesignal-notifications-engineer.base.md](roles/senior-onesignal-notifications-engineer.base.md) | OneSignal, push notifications, email notifications, mention alerts, desktop notifications, mobile notifications, notification preferences, deep links, deliverability, notification architecture | +| **Senior OneSignal Expert** | [roles/senior-onesignal-expert.base.md](roles/senior-onesignal-expert.base.md) | OneSignal expert alias, mention notifications, push/email consent, notification delivery strategy, deep links | | **Senior Neon Database Engineer** | [roles/senior-neon-db-engineer.base.md](roles/senior-neon-db-engineer.base.md) | Neon PostgreSQL, database schema design, migrations, query performance, index optimization, connection pooling, Row-Level Security, database InfoSec, Drizzle ORM, branching, `storage-postgres` | | **Senior QA / Test Engineer** | [roles/senior-qa-test-engineer.base.md](roles/senior-qa-test-engineer.base.md) | QA strategy, Playwright E2E tests, test automation, accessibility testing, visual regression, performance testing, CI/CD test integration, WCAG compliance, axe-core, Vitest, test coverage | | **Senior Application Security Engineer** | [roles/senior-application-security-engineer.base.md](roles/senior-application-security-engineer.base.md) | Application security, OWASP Top 10, threat modeling, secure code review, XSS, CSRF, SSRF, injection, auth bypass, CSP, CORS, security headers, dependency audit, supply chain security, secrets management, penetration testing, ASVS | diff --git a/.agents/roles/senior-onesignal-expert.base.md b/.agents/roles/senior-onesignal-expert.base.md new file mode 100644 index 0000000000..8b8420e06f --- /dev/null +++ b/.agents/roles/senior-onesignal-expert.base.md @@ -0,0 +1,9 @@ +# Senior OneSignal Expert System Message + +This role is an alias for the OneSignal specialization used in Hypha. + +Use the full role definition in: + +- [Senior OneSignal Notifications Engineer](./senior-onesignal-notifications-engineer.base.md) + +When activated, follow that role's identity, protocols, and quality checklist exactly. diff --git a/.agents/roles/senior-onesignal-notifications-engineer.base.md b/.agents/roles/senior-onesignal-notifications-engineer.base.md new file mode 100644 index 0000000000..6f1d1743b3 --- /dev/null +++ b/.agents/roles/senior-onesignal-notifications-engineer.base.md @@ -0,0 +1,136 @@ +# Senior OneSignal Notifications Engineer System Message + +You are a senior notifications engineer specializing in OneSignal across web, desktop, email, and mobile push delivery. You design and implement reliable mention-driven notification systems for real-time chat products, with deep expertise in event pipelines, deliverability, user preference models, and deep-link routing. + +**IMPORTANT:** You ALWAYS check the official OneSignal documentation at `https://documentation.onesignal.com/` before answering questions about SDK APIs, REST fields, aliases, subscriptions, segmentation, templates, or channel behavior. Notification APIs evolve quickly, so you prioritize current, version-accurate guidance over assumptions. + +--- + +## Core Competencies + +### Notification Platform + +1. [Critical Analysis](../references/competencies/critical-analysis.md) +2. [Agile Delivery](../references/competencies/agile-delivery.md) + +### Supporting Engineering Competencies + +3. [Requirements Engineering](../references/competencies/requirements-engineering.md) +4. [Information Synthesis](../references/competencies/information-synthesis.md) + +### Domain Specialization + +Experienced in production notification engineering across multiple dimensions: + +- **OneSignal Architecture:** App IDs, users/subscriptions, aliases and external IDs, segments, outcomes, templates, and channel orchestration. +- **Real-Time Triggering:** Mention/event fanout pipelines, idempotent delivery jobs, dedupe keys, retry/backoff, and dead-letter handling. +- **Cross-Channel Delivery:** Web push (service worker), desktop notifications, email notifications, and mobile push workflows with channel fallback. +- **Preference & Policy Models:** Per-user and per-room notification settings, mentions-only vs all messages, mute, quiet hours, and default inheritance. +- **Deep Link Reliability:** Deterministic URLs to room/thread/message targets with auth-aware routing and read-state transitions. +- **Observability & Quality:** Delivery metrics, open/click tracking, webhook feedback, bounce handling, and incident triage for silent notification failures. + +--- + +## Methodologies + +1. [Development Lifecycle](../references/methodologies/development-lifecycle.md) + +--- + +## Best Practices + +1. [Code Quality Best Practices](../references/best-practices/code-quality.md) +2. [Truthfulness & Integrity](../references/best-practices/truthfulness-integrity.md) + +--- + +## Collaboration + +[Cross-Functional Collaboration](../references/collaboration/cross-functional-teams.md) + +--- + +## Tools & Techniques + +[Development Tooling](../references/tools/development-tooling.md) + +--- + +## Engagement Model + +[Implementation Engagement Model](../references/engagement-models/implementation-engagement.md) + +--- + +## Output Standards + +1. [Code Output Standards](../references/output-standards/code-output-standards.md) +2. [Actionable Recommendations](../references/output-standards/actionable-recommendations.md) + +--- + +## Notification Engineering Philosophy + +Design notifications so users trust them: timely, relevant, and never noisy. + +- Mentions are high-intent events and must be delivered quickly and consistently. +- Delivery is not success; actionability is success (deep links must land in exact context). +- Preference controls must be predictable and explicit (inherit, override, mute). +- Every notification path needs observability and replay-safe behavior. +- Quiet defaults beat spam defaults; escalation should be intentional. + +--- + +## OneSignal Delivery Playbook + +When implementing mention-driven notifications: + +1. **Model the event contract** — Define mention event shape, IDs, actor/target, room/thread/message references, and idempotency key. +2. **Map recipients to OneSignal identity** — Resolve aliases/external IDs to active subscriptions by channel. +3. **Apply preferences before send** — Enforce user/channel policy (mentions-only, all, mute) before queueing. +4. **Send across channels with guardrails** — Push first, email fallback when user is offline or unsubscribed from push. +5. **Generate exact deep links** — Include room/thread/message route data and resilient post-login redirect behavior. +6. **Observe and verify** — Track send, delivery, click-through, and failures with actionable alerts. + +--- + +## Documentation-First Protocol + +**CRITICAL:** Before answering any question about OneSignal setup or delivery behavior: + +1. **Check OneSignal Docs** — Reference `https://documentation.onesignal.com/` for current SDK and REST semantics. +2. **Verify channel context** — Distinguish web push vs mobile push vs email requirements and constraints. +3. **Confirm identity model** — Verify whether aliases/external IDs/subscriptions are used in the project and map correctly. +4. **Check platform prerequisites** — Service worker, origin constraints, keys, and app configuration for the relevant environment. +5. **Cite sources** — Reference relevant documentation pages when giving recommendations. + +--- + +## Quality Checklist + +Before delivering guidance or implementation plans, verify: + +- [ ] OneSignal docs were checked for each used API field/SDK behavior. +- [ ] Mention trigger and idempotency rules are clearly defined. +- [ ] Deep links include room/thread/message and auth-safe routing behavior. +- [ ] Notification preferences and overrides are explicit and testable. +- [ ] Retry, dedupe, and failure observability are addressed. +- [ ] Web, desktop, and mobile channel coverage is explicit. +- [ ] Recommendations are maintainable in a multi-team codebase. + +--- + +## Response Protocol + +When given a notification challenge: + +1. **Verify docs first** — Check OneSignal docs for exact API/SDK behavior. +2. **Clarify event flow** — Identify source event, recipients, channels, and intended UX outcome. +3. **Design the contract** — Define payload schema, deep-link contract, and preference gates. +4. **Propose implementation slices** — Break down into backend trigger, client routing, and QA instrumentation steps. +5. **Validate operationally** — Include metrics, alert thresholds, and rollback/mitigation guidance. +6. **Ship with tests** — Include unit/integration/e2e validation across mention creation and open-through flow. + +--- + +_Remember: a great chat notification system is invisible when correct and painful when wrong. Optimize for trust, timeliness, and precision._ diff --git a/docs/requirements/Features/chat-mentions-notifications-discord-parity/requirements.md b/docs/requirements/Features/chat-mentions-notifications-discord-parity/requirements.md new file mode 100644 index 0000000000..8a29a7ab02 --- /dev/null +++ b/docs/requirements/Features/chat-mentions-notifications-discord-parity/requirements.md @@ -0,0 +1,185 @@ +# Discord-parity chat mentions and notifications + +**Status:** Ready for implementation +**Primary objective:** Restore reliable `@` mention behavior in room + thread chat and deliver multi-channel notifications that bring users back to the platform. + +## 1. Problem statement + +Current behavior is below user expectations for real-time collaboration: + +- `@` mention flow is unreliable in thread context. +- Mention-triggered notifications are not consistently delivered. +- Users outside the app are not reliably re-engaged by desktop/mobile/email alerts. +- Mention navigation and unread mention feedback are weaker than Discord-like products. + +This specification defines a production-ready, implementable path to fix mention reliability and establish Discord-comparable notification UX. + +## 2. Scope + +In scope: + +- Mention creation in room chat and thread chat (Matrix-backed). +- Mention unread badge on bell icon with read/unread lifecycle. +- Mention inbox list with click-through to exact thread/message. +- Notification fanout for mentions: desktop push, mobile push, and one email per mention. +- Notification centre consent control for mention notifications. +- OneSignal integration contract and delivery guarantees. + +Out of scope: + +- Non-mention notification classes (reactions, joins, follows, digest emails). +- Full thread-level server-side push rules beyond room-level Matrix capabilities. +- Notification provider migration away from OneSignal. + +## 3. Existing touchpoints + +- Mention parsing/sending: `packages/core/src/matrix/mentions.ts` +- Chat composer + mention picker: `packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx` +- Mention inbox UI: `packages/epics/src/common/human-chat-panel/human-chat-panel-mention-inbox.tsx` +- OneSignal integration: `packages/notifications/src/sdk/send-push.ts` +- OneSignal package docs: `packages/notifications/README.md` + +## 4. User stories + +1. As a user, I can `@` mention someone in room and thread chat so they are explicitly notified. +2. As a mentioned user, I receive a notification when I am offline (desktop/mobile) and an email with a deep link. +3. As a mentioned user, I see an unread mention counter on the bell until I read the mention. +4. As a user, I can open a mentions list and click any mention to navigate to the correct thread and message. +5. As a power user, I can control mention notification preferences without missing critical messages. + +## 5. Functional requirements + +### 5.1 Mention creation and send pipeline + +**FR-1** The system SHALL support mention insertion from the composer in both room and thread contexts. + +**FR-2** The system SHALL include Matrix intentional mentions (`m.mentions.user_ids`) on every outgoing message containing mention tokens. + +**FR-3** The system SHALL preserve mention metadata when sending replies and thread messages. + +**FR-4** The system SHALL resolve mention candidates from room membership and reject invalid/stale targets. + +### 5.2 Mention unread and bell behavior + +**FR-5** The bell icon SHALL display the unread mention count for the signed-in user. + +**FR-6** The unread mention count SHALL decrement only when a mention is marked as read (navigated + read receipt or explicit mark-read action). + +**FR-7** The counter SHALL disappear when unread mention count reaches zero. + +### 5.3 Mention inbox and navigation + +**FR-8** The mentions inbox SHALL list mention events sorted by newest first. + +**FR-9** Each mention row SHALL include sender, context label (space/room/thread), excerpt, and timestamp. + +**FR-10** Clicking a mention row SHALL navigate to the exact target thread/message. + +**FR-11** If direct navigation fails (message unavailable), the system SHALL fall back to thread root and show a non-blocking error message. + +### 5.4 OneSignal-driven delivery + +**FR-12** For each new mention, the system SHALL send a desktop push notification when the user has an active web push subscription. + +**FR-13** For each new mention, the system SHALL send a mobile push notification when the user has an active mobile subscription. + +**FR-14** For each new mention, the system SHALL send one email notification containing a deep link to the exact thread/message. + +**FR-15** Notification payloads SHALL include stable deep-link identifiers (`spaceSlug`, `roomId`, `threadRootEventId`, `eventId`) to reconstruct navigation. + +**FR-16** The send pipeline SHALL be idempotent per mention event and recipient, preventing duplicate push/email sends. + +### 5.5 Preferences and policy + +**FR-17** Users SHALL be able to choose per-context notification policy: `All messages`, `Mentions only`, `Muted`. + +**FR-18** Mention notifications SHALL respect user-level channel preferences (desktop push/email/mobile push toggles). + +**FR-19** The notification centre SHALL include an explicit user consent control for mention notifications (for example: `Allow mention notifications`) that applies across delivery channels unless a stricter per-channel opt-out is selected. + +**FR-20** The mention-consent value SHALL be persisted with user notification preferences and SHALL default to `enabled` for existing users unless legal/product policy requires opt-in by region. + +**FR-21** If mention consent is disabled, the system SHALL suppress mention-triggered desktop push, mobile push, and mention emails for that user. + +## 6. Non-functional requirements + +**NFR-1 (Latency)** Mention-triggered push and email dispatch SHALL be initiated within 60 seconds of mention event creation. + +**NFR-2 (Reliability)** Notification pipeline SHALL provide at-least-once delivery attempts with dedupe guarantees. + +**NFR-3 (Observability)** System SHALL log mention notification lifecycle states: `queued`, `sent`, `provider_accepted`, `provider_failed`, `opened`, `clicked`. + +**NFR-4 (Security)** Deep links SHALL require normal application auth and SHALL never expose private content via unauthenticated endpoints. + +**NFR-5 (Accessibility)** Bell badge and mention inbox interactions SHALL be keyboard/screen-reader accessible. + +## 7. Data and integration contract + +### 7.1 Mention event envelope + +Each mention event SHALL include at minimum: + +- `mentionEventId` (idempotency key seed) +- `actorMatrixUserId` +- `targetMatrixUserId` +- `roomId` +- `threadRootEventId` (nullable) +- `messageEventId` +- `createdAt` + +### 7.2 OneSignal payload contract + +Push/email payload SHALL include: + +- Title: `"{actorName} mentioned you"` +- Body/excerpt: truncated message preview +- URL/deep link: canonical route to the referenced thread/message +- Data payload keys: `roomId`, `threadRootEventId`, `eventId`, `spaceSlug`, `type="mention"` + +## 8. Implementation decomposition + +1. **Mention reliability hardening** + - Validate thread mention parsing and send in Matrix provider path. + - Ensure `m.mentions` survives reply/thread relation wrappers. +2. **Unread mention source of truth** + - Consolidate unread mention counting (highlight + mention event list alignment). + - Define read transition behavior and persistence. +3. **Navigation correctness** + - Standardize mention deep-link resolver and fallback behavior. +4. **Notification dispatcher** + - Add mention-triggered server action/job using OneSignal alias targeting. + - Add idempotency storage/check. +5. **Email parity** + - Add mention email template and per-mention send path. +6. **Mobile parity** + - Verify mobile subscription identifiers and payload compatibility. +7. **Preference enforcement** + - Gate sends by room policy and channel-level user preferences. + - Add notification centre mention-consent control and include it in save/load validation. +8. **Instrumentation + dashboards** + - Add metrics and logs for delivery and click/open outcomes. + +## 9. Acceptance criteria + +**AC-1** Given a user mentions another user in a thread, when message is sent, then target receives a valid mention event and `m.mentions` includes target ID. + +**AC-2** Given target user is offline with desktop/mobile subscriptions, when mentioned, then desktop and mobile notifications are delivered with valid deep links. + +**AC-3** Given target user has email mentions enabled, when mentioned, then one email is sent for that mention with a deep link to the correct thread/message. + +**AC-4** Given unread mentions exist, when viewing chat header, then bell shows exact unread mention count. + +**AC-5** Given user opens mention from inbox, when navigation completes, then the correct thread/message is visible and unread count updates accordingly. + +**AC-6** Given all unread mentions are read, when header re-renders, then bell counter is hidden. + +**AC-7** Given a user disables mention consent in notification centre, when another user mentions them, then no mention push/email/mobile notification is sent. + +## 10. Discord-comparable improvements (recommended follow-ups) + +1. Add per-server/per-channel notification presets with bulk controls. +2. Add mention digest fallback (15m/1h) only for users who disable per-mention emails. +3. Add “inbox triage” actions: mark read, snooze, mute thread, jump to first unread. +4. Add notification sound controls and quiet hours by timezone. +5. Add trust indicators: “delivered”, “opened”, and “last notified” for debugging silent failures. +6. Add anti-spam controls: suppress repeated mention bursts from same actor within short windows. diff --git a/packages/epics/src/notifications/components/notification-centre-form.tsx b/packages/epics/src/notifications/components/notification-centre-form.tsx index 1c774c1888..8f6d71b77a 100644 --- a/packages/epics/src/notifications/components/notification-centre-form.tsx +++ b/packages/epics/src/notifications/components/notification-centre-form.tsx @@ -90,6 +90,9 @@ export const NotificationCentreForm = ({ browserNotifications: configuration ? getSwitch(configuration.browserNotifications) : 'yes', + mentionNotificationsConsent: configuration + ? getSwitch(configuration.mentionNotificationsConsent) + : 'yes', subscriptions: NOTIFICATION_SUBSCRIPTIONS, }, mode: 'onChange', @@ -103,6 +106,9 @@ export const NotificationCentreForm = ({ const modified = { browserNotifications: getSwitch(configuration.browserNotifications), emailNotifications: getSwitch(configuration.emailNotifications), + mentionNotificationsConsent: getSwitch( + configuration.mentionNotificationsConsent, + ), subscriptions: NOTIFICATION_SUBSCRIPTIONS.map((subscription) => { const sub = configuration.subscriptions?.find( (s) => s.name === subscription.tagName, @@ -124,6 +130,9 @@ export const NotificationCentreForm = ({ await saveConfigurations({ browserNotifications: parseSwitch(values.browserNotifications), emailNotifications: parseSwitch(values.emailNotifications), + mentionNotificationsConsent: parseSwitch( + values.mentionNotificationsConsent, + ), subscriptions: values.subscriptions.map((subscription) => { return { name: subscription.tagName, @@ -288,6 +297,43 @@ export const NotificationCentreForm = ({ )} /> + + + {tNotificationCentre.has('channels.mentionConsent') + ? tNotificationCentre('channels.mentionConsent') + : 'Mention Notifications Consent'} + + ( + { + form.setValue(field.name, parseYesNoValue(value, 'yes')); + }} + > + + + + + + )} + /> +

diff --git a/packages/epics/src/notifications/hooks/validation.ts b/packages/epics/src/notifications/hooks/validation.ts index dd62e46275..c7ffb3281d 100644 --- a/packages/epics/src/notifications/hooks/validation.ts +++ b/packages/epics/src/notifications/hooks/validation.ts @@ -14,6 +14,7 @@ export const notificationSubscriptionSchema = z.object({ export const schemaNotificationCentreForm = z.object({ emailNotifications: yesNoEnum.default('no'), browserNotifications: yesNoEnum.default('no'), + mentionNotificationsConsent: yesNoEnum.default('yes'), subscriptions: z.array(notificationSubscriptionSchema), }); diff --git a/packages/i18n/src/messages/de.json b/packages/i18n/src/messages/de.json index e38bd8a4d1..8de81902d2 100644 --- a/packages/i18n/src/messages/de.json +++ b/packages/i18n/src/messages/de.json @@ -161,6 +161,7 @@ "emailNotifications": "E-Mail-Benachrichtigungen", "toEmail": "an {email}", "browserNotifications": "Browser-Benachrichtigungen (früher „Desktop-Pop-up-Benachrichtigungen“)", + "mentionConsent": "Einwilligung für Erwähnungsbenachrichtigungen", "yes": "Ja", "no": "Nein", "emailNotificationsWithEmail": "E-Mail-Benachrichtigungen ({email})" diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index 210e6e24bf..3c0cbbdfc2 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -160,6 +160,7 @@ "emailNotifications": "Email Notifications", "toEmail": "to {email}", "browserNotifications": "Browser Notifications (formerly \"Desktop Pop-up Notifications\")", + "mentionConsent": "Mention Notifications Consent", "yes": "Yes", "no": "No", "emailNotificationsWithEmail": "Email Notifications ({email})" diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 32673dbb0f..9cd8485820 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -851,6 +851,7 @@ "emailNotifications": "Notificaciones por correo electrónico", "toEmail": "a {email}", "browserNotifications": "Notificaciones del navegador (anteriormente \"Notificaciones emergentes de escritorio\")", + "mentionConsent": "Consentimiento para notificaciones de menciones", "yes": "Sí", "no": "No", "emailNotificationsWithEmail": "Notificaciones por correo electrónico ({email})" diff --git a/packages/i18n/src/messages/fr.json b/packages/i18n/src/messages/fr.json index 75b38a7103..16fc1cbf6e 100644 --- a/packages/i18n/src/messages/fr.json +++ b/packages/i18n/src/messages/fr.json @@ -161,6 +161,7 @@ "emailNotifications": "Notifications par e-mail", "toEmail": "à {email}", "browserNotifications": "Notifications du navigateur (anciennement « Notifications pop-up bureau »)", + "mentionConsent": "Consentement aux notifications de mention", "yes": "Oui", "no": "Non", "emailNotificationsWithEmail": "Notifications par e-mail ({email})" diff --git a/packages/i18n/src/messages/pt.json b/packages/i18n/src/messages/pt.json index 4f4ab064ea..08386debef 100644 --- a/packages/i18n/src/messages/pt.json +++ b/packages/i18n/src/messages/pt.json @@ -851,6 +851,7 @@ "emailNotifications": "Notificações por e-mail", "toEmail": "para {email}", "browserNotifications": "Notificações do navegador (anteriormente \"Notificações pop-up na área de trabalho\")", + "mentionConsent": "Consentimento para notificações de menções", "yes": "Sim", "no": "Não", "emailNotificationsWithEmail": "Notificações por e-mail ({email})" diff --git a/packages/notifications/src/constants/tags.ts b/packages/notifications/src/constants/tags.ts index 33c55891bf..960d58cbe7 100644 --- a/packages/notifications/src/constants/tags.ts +++ b/packages/notifications/src/constants/tags.ts @@ -1,12 +1,18 @@ export const TAG_SUBSCRIBED = 'subscribed'; export const TAG_PUSH = 'push'; export const TAG_EMAIL = 'email'; +export const TAG_MENTION_CONSENT = 'consent_mentions'; export const TAG_SUB_NEW_PROPOSAL_OPEN = 'sub_newProposalOpen'; export const TAG_SUB_PROPOSAL_APPROVED_OR_REJECTED = 'sub_proposalApprovedOrRejected'; -export const MAIN_TAGS = [TAG_SUBSCRIBED, TAG_PUSH, TAG_EMAIL] as const; +export const MAIN_TAGS = [ + TAG_SUBSCRIBED, + TAG_PUSH, + TAG_EMAIL, + TAG_MENTION_CONSENT, +] as const; export const SUBSCRIPTION_TAGS = [ TAG_SUB_NEW_PROPOSAL_OPEN, TAG_SUB_PROPOSAL_APPROVED_OR_REJECTED, diff --git a/packages/notifications/src/hooks/use-notifications.ts b/packages/notifications/src/hooks/use-notifications.ts index 69b8306a8d..f1efe57d83 100644 --- a/packages/notifications/src/hooks/use-notifications.ts +++ b/packages/notifications/src/hooks/use-notifications.ts @@ -8,6 +8,7 @@ import { SubscriptionTag, Tag, TAG_EMAIL, + TAG_MENTION_CONSENT, TAG_PUSH, TAG_SUBSCRIBED, } from '../constants'; @@ -26,6 +27,7 @@ export interface INotificationsContext { export interface NotificationConfiguration { emailNotifications: boolean; browserNotifications: boolean; + mentionNotificationsConsent: boolean; subscriptions: { name: SubscriptionTag; value: boolean; @@ -87,6 +89,11 @@ export const useNotifications = ({ const tags = await OneSignal.User.getTags(); const browserNotifications = checkTag(tags, TAG_PUSH, true); const emailNotifications = checkTag(tags, TAG_EMAIL, true); + const mentionNotificationsConsent = checkTag( + tags, + TAG_MENTION_CONSENT, + true, + ); const subscriptions = SUBSCRIPTION_TAGS.map((tagName) => ({ name: tagName, value: checkTag(tags, tagName, true), @@ -94,6 +101,7 @@ export const useNotifications = ({ setConfiguration({ browserNotifications, emailNotifications, + mentionNotificationsConsent, subscriptions, }); } catch (err) { @@ -171,6 +179,10 @@ export const useNotifications = ({ } await OneSignal.User.addTag(TAG_EMAIL, FALSE); } + await OneSignal.User.addTag( + TAG_MENTION_CONSENT, + configuration.mentionNotificationsConsent ? TRUE : FALSE, + ); for (const subscription of configuration.subscriptions) { const tagName = subscription.name; const tagValue = subscription.value ? TRUE : FALSE; From aa7f1999eee9766dff9ec8ef0291a1257fef0a56 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Tue, 19 May 2026 13:31:23 +0200 Subject: [PATCH 02/83] feat(chat): dispatch mention push/email notifications after send Add chat mention notification dispatch that maps mentioned Matrix users to Hypha recipients, enforces mention consent tags, and sends push/email notifications with deep links to the mentioned message. Also return sent event ids from Matrix sendMessage and wire client notification hooks for chat mentions. Co-authored-by: Cursor --- .../client/providers/matrix-provider.tsx | 39 ++++--- .../client/hooks/use-send-notifications.ts | 9 ++ .../epics/src/common/human-right-panel.tsx | 48 +++++++- packages/notifications/src/actions/index.ts | 1 + .../src/actions/notify-chat-mention.ts | 107 ++++++++++++++++++ .../src/hooks/use-send-notifications.ts | 9 ++ 6 files changed, 199 insertions(+), 14 deletions(-) create mode 100644 packages/notifications/src/actions/notify-chat-mention.ts diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index 6c6499813e..28be56456f 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -62,6 +62,10 @@ export interface SendMessageInput { signal?: AbortSignal; } +export interface SendMessageResult { + eventId?: string; +} + /** Existing attachment slot when editing a media `m.room.message` (mxc stays on server). */ export type EditRoomMessageExistingSlot = { mxcUrl: string; @@ -254,6 +258,12 @@ function delay(ms: number): Promise { }); } +function getEventIdFromSendResponse(response: unknown): string | undefined { + if (!response || typeof response !== 'object') return undefined; + const eventId = (response as { event_id?: unknown }).event_id; + return typeof eventId === 'string' && eventId.trim() ? eventId : undefined; +} + /** True when the homeserver rejected the request for rate limiting (HTTP 429 / M_LIMIT_EXCEEDED). */ export function isMatrixRateLimitedError(err: unknown): boolean { if (!(err instanceof Error)) return false; @@ -438,7 +448,7 @@ interface MatrixContextType { isMatrixAvailable: boolean; isAuthenticated: boolean; createRoom: (title: string) => Promise<{ roomId: string }>; - sendMessage: (params: SendMessageInput) => Promise; + sendMessage: (params: SendMessageInput) => Promise; editRoomMessage: (params: EditRoomMessageInput) => Promise; redactRoomEvent: (params: RedactRoomEventInput) => Promise; toggleReaction: (params: ToggleReactionInput) => Promise; @@ -669,19 +679,19 @@ export const MatrixProvider: React.FC = ({ children }) => { attachments, onUploadProgress, signal, - }: SendMessageInput) => { + }: SendMessageInput): Promise => { if (!client) { throw new Error('Client should be specified'); } if (!roomId?.trim()) { - return; + return {}; } const trimmed = message.trim(); const list = attachments?.length ? attachments : []; const hasAttachments = list.length > 0; if (!trimmed && !hasAttachments) { - return; + return {}; } const mentionIds = resolveMentionUserIdsForSend(trimmed, mentionUserIds); @@ -790,7 +800,7 @@ export const MatrixProvider: React.FC = ({ children }) => { } : base; throwIfAborted(signal); - await client.sendEvent( + const sendResult = await client.sendEvent( roomId, EventType.RoomMessage, mergeMatrixMentionsIntoContent( @@ -799,6 +809,7 @@ export const MatrixProvider: React.FC = ({ children }) => { ) as RoomMessageEventContent, ); sentMediaCount = 1; + return { eventId: getEventIdFromSendResponse(sendResult) }; } else if (mediaPayloads.length > 1) { const [first, ...rest] = mediaPayloads; const bundleItems: HyphaMediaBundleItemWire[] = rest.map((item) => { @@ -829,7 +840,7 @@ export const MatrixProvider: React.FC = ({ children }) => { } : combined; throwIfAborted(signal); - await client.sendEvent( + const sendResult = await client.sendEvent( roomId, EventType.RoomMessage, mergeMatrixMentionsIntoContent( @@ -838,6 +849,7 @@ export const MatrixProvider: React.FC = ({ children }) => { ) as RoomMessageEventContent, ); sentMediaCount = list.length; + return { eventId: getEventIdFromSendResponse(sendResult) }; } } catch (mediaErr) { throw new SendMessagePartialFailureError( @@ -848,11 +860,11 @@ export const MatrixProvider: React.FC = ({ children }) => { true, ); } - return; + return {}; } if (!trimmed) { - return; + return {}; } throwIfAborted(signal); @@ -865,7 +877,7 @@ export const MatrixProvider: React.FC = ({ children }) => { message, ); throwIfAborted(signal); - await client.sendEvent( + const sendResult = await client.sendEvent( roomId, EventType.RoomMessage, mergeMatrixMentionsIntoContent( @@ -881,14 +893,14 @@ export const MatrixProvider: React.FC = ({ children }) => { mentionIds, ), ); - return; + return { eventId: getEventIdFromSendResponse(sendResult) }; } if (replyContext && hasAttachments) { const textPayload = matrixTextEventContentWithOptionalFormatting(message); throwIfAborted(signal); - await client.sendEvent( + const sendResult = await client.sendEvent( roomId, EventType.RoomMessage, mergeMatrixMentionsIntoContent( @@ -904,13 +916,13 @@ export const MatrixProvider: React.FC = ({ children }) => { mentionIds, ), ); - return; + return { eventId: getEventIdFromSendResponse(sendResult) }; } const textPayload = matrixTextEventContentWithOptionalFormatting(message); throwIfAborted(signal); - await client.sendEvent( + const sendResult = await client.sendEvent( roomId, EventType.RoomMessage, mergeMatrixMentionsIntoContent( @@ -921,6 +933,7 @@ export const MatrixProvider: React.FC = ({ children }) => { mentionIds, ), ); + return { eventId: getEventIdFromSendResponse(sendResult) }; } catch (textErr) { throw new SendMessagePartialFailureError( textErr instanceof Error diff --git a/packages/core/src/notifications/client/hooks/use-send-notifications.ts b/packages/core/src/notifications/client/hooks/use-send-notifications.ts index cf73bfc411..26f0189c05 100644 --- a/packages/core/src/notifications/client/hooks/use-send-notifications.ts +++ b/packages/core/src/notifications/client/hooks/use-send-notifications.ts @@ -11,6 +11,14 @@ export interface NotifyProposalRejectedInput { proposalId: bigint; } +export interface NotifyChatMentionInput { + actorSlug?: string; + actorDisplayName?: string; + mentionMatrixUserIds: string[]; + messagePreview?: string; + url: string; +} + export interface PostNotifyProposalCreatedInput extends NotifyProposalCreatedInput { sendNotifications?: (arg: NotifyProposalCreatedInput) => Promise; @@ -20,6 +28,7 @@ export interface UseSendNotificationsReturn { notifyProposalCreated: (arg: NotifyProposalCreatedInput) => Promise; notifyProposalAccepted: (arg: NotifyProposalAcceptedInput) => Promise; notifyProposalRejected: (arg: NotifyProposalRejectedInput) => Promise; + notifyChatMention: (arg: NotifyChatMentionInput) => Promise; } export interface UseSendNotificationsInput { diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx index a18e56d61d..9ad0b0a765 100644 --- a/packages/epics/src/common/human-right-panel.tsx +++ b/packages/epics/src/common/human-right-panel.tsx @@ -26,6 +26,7 @@ import { useAuthentication } from '@hypha-platform/authentication'; import { useMatrix, useCoherenceMutationsWeb2Rsc, + useHookRegistry, useJwt, useMatrixUserIdsByPrivySubs, useMe, @@ -579,6 +580,8 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { openHumanChatPanel, } = useHumanChatPanel(); const { jwt: authToken } = useJwt(); + const { useSendNotifications } = useHookRegistry(); + const { notifyChatMention } = useSendNotifications({ authToken }); const { person: me } = useMe(); const { persons: spaceMembersResult } = useMembers({ spaceSlug, @@ -2274,7 +2277,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { }); } } else { - await matrixRef.current.sendMessage({ + const sendResult = await matrixRef.current.sendMessage({ roomId, message: wirePlain, mentionUserIds, @@ -2305,6 +2308,41 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { } : {}), }); + const mentionTargets = mentionUserIds.filter( + (matrixId) => matrixId !== currentUserIdRef.current, + ); + if ( + mentionTargets.length > 0 && + sendResult.eventId && + mode === 'space' + ) { + const params = new URLSearchParams( + searchParams?.toString() ?? undefined, + ); + params.set('msg', sendResult.eventId); + params.set('chat', roomId); + const query = params.toString(); + const deepLink = + typeof window !== 'undefined' + ? `${window.location.origin}${pathname}${query ? `?${query}` : ''}` + : pathname; + const actorDisplayName = + [me?.name, me?.surname].filter(Boolean).join(' ').trim() || + me?.nickname?.trim() || + t('you'); + void notifyChatMention({ + actorSlug: me?.slug, + actorDisplayName, + mentionMatrixUserIds: mentionTargets, + messagePreview: wirePlain.trim().slice(0, 220), + url: deepLink, + }).catch((notifyErr) => { + console.warn( + '[HumanRightPanel] Mention notification dispatch failed:', + notifyErr, + ); + }); + } } setSendingPending(null); disposeDraftAttachmentUrls(savedAttachments); @@ -2374,6 +2412,14 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { editDraft, draftAttachments, mentionSanitizedLabelToUserId, + mode, + searchParams, + pathname, + me?.name, + me?.surname, + me?.nickname, + me?.slug, + notifyChatMention, t, ]); diff --git a/packages/notifications/src/actions/index.ts b/packages/notifications/src/actions/index.ts index ba336db86d..e785735fbd 100644 --- a/packages/notifications/src/actions/index.ts +++ b/packages/notifications/src/actions/index.ts @@ -1,3 +1,4 @@ export * from './notify-proposal-created'; export * from './notify-proposal-accepted'; export * from './notify-proposal-rejected'; +export * from './notify-chat-mention'; diff --git a/packages/notifications/src/actions/notify-chat-mention.ts b/packages/notifications/src/actions/notify-chat-mention.ts new file mode 100644 index 0000000000..186b735ec1 --- /dev/null +++ b/packages/notifications/src/actions/notify-chat-mention.ts @@ -0,0 +1,107 @@ +'use server'; + +import { eq, inArray } from 'drizzle-orm'; +import { + matrixUserLinks, + people, + db, +} from '@hypha-platform/storage-postgres'; +import { NotifyChatMentionInput } from '@hypha-platform/core/client'; +import { + sendEmailNotifications, + sendPushNotifications, +} from '../mutations'; +import { TAG_MENTION_CONSENT } from '../constants'; + +function sanitizeMentionIds(ids: string[]): string[] { + return [...new Set(ids.map((id) => id.trim()).filter(Boolean))]; +} + +function buildMentionEmailBody({ + actorDisplayName, + messagePreview, + url, +}: { + actorDisplayName: string; + messagePreview: string; + url: string; +}): string { + const escapedActor = actorDisplayName.replace(//g, '>'); + const escapedPreview = messagePreview + .replace(//g, '>'); + return ` +

${escapedActor} mentioned you in chat.

+

${escapedPreview || 'Open chat to view the message.'}

+

Open mention

+ `.trim(); +} + +export async function notifyChatMentionAction( + { + actorSlug, + actorDisplayName, + mentionMatrixUserIds, + messagePreview, + url, + }: NotifyChatMentionInput, + { authToken }: { authToken?: string }, +) { + if (!authToken) { + throw new Error('authToken is required to send mention notifications'); + } + const matrixIds = sanitizeMentionIds(mentionMatrixUserIds); + if (matrixIds.length === 0) return; + + const recipients = await db + .select({ + slug: people.slug, + matrixUserId: matrixUserLinks.matrixUserId, + }) + .from(matrixUserLinks) + .innerJoin(people, eq(matrixUserLinks.privyUserId, people.sub)) + .where(inArray(matrixUserLinks.matrixUserId, matrixIds)); + + const usernames = [ + ...new Set( + recipients + .map((r) => r.slug?.trim()) + .filter((slug): slug is string => Boolean(slug && slug !== actorSlug)), + ), + ]; + if (usernames.length === 0) return; + + const safeActor = actorDisplayName?.trim() || 'Someone'; + const safePreview = messagePreview?.trim() || ''; + const subject = `${safeActor} mentioned you`; + const contents = { + en: safePreview || `${safeActor} mentioned you in chat.`, + }; + const headings = { + en: `${safeActor} mentioned you`, + }; + + const requiredTags = { + [TAG_MENTION_CONSENT]: 'true', + }; + + await Promise.allSettled([ + sendPushNotifications({ + contents, + headings, + usernames, + requiredTags, + url, + }), + sendEmailNotifications({ + subject, + body: buildMentionEmailBody({ + actorDisplayName: safeActor, + messagePreview: safePreview, + url, + }), + usernames, + requiredTags, + }), + ]); +} diff --git a/packages/notifications/src/hooks/use-send-notifications.ts b/packages/notifications/src/hooks/use-send-notifications.ts index 502296a702..035c362de1 100644 --- a/packages/notifications/src/hooks/use-send-notifications.ts +++ b/packages/notifications/src/hooks/use-send-notifications.ts @@ -2,6 +2,7 @@ import useSWRMutation from 'swr/mutation'; import { + NotifyChatMentionInput, NotifyProposalAcceptedInput, NotifyProposalCreatedInput, NotifyProposalRejectedInput, @@ -10,6 +11,7 @@ import { UseSendNotificationsReturn, } from '@hypha-platform/core/client'; import { + notifyChatMentionAction, notifyProposalAcceptedAction, notifyProposalCreatedAction, notifyProposalRejectedAction, @@ -46,9 +48,16 @@ export const useSendNotifications: UseSendNotificationsHook = ({ notifyProposalRejectedAction(arg, { authToken }), ); + const { trigger: notifyChatMention } = useSWRMutation( + authToken ? [authToken, 'notifyChatMention'] : null, + async ([authToken], { arg }: { arg: NotifyChatMentionInput }) => + notifyChatMentionAction(arg, { authToken }), + ); + return { notifyProposalCreated: authToken ? notifyProposalCreated : noOp, notifyProposalAccepted: authToken ? notifyProposalAccepted : noOp, notifyProposalRejected: authToken ? notifyProposalRejected : noOp, + notifyChatMention: authToken ? notifyChatMention : noOp, }; }; From a32dcef40490798b06b30a47c0544f987e357b26 Mon Sep 17 00:00:00 2001 From: Alex Prate Date: Tue, 19 May 2026 13:40:52 +0200 Subject: [PATCH 03/83] fix(chat): harden mention deep-link fallback and add helper tests Add resilient mention navigation fallback when a target message or room cannot be resolved, with non-blocking user notices and localized copy. Also extract and test mention notification helper utilities to improve confidence in mention recipient normalization and email body rendering. Co-authored-by: Cursor --- .../human-chat-panel-messages.tsx | 31 ++++++++++++- .../epics/src/common/human-right-panel.tsx | 23 ++++++++++ packages/i18n/src/messages/de.json | 2 + packages/i18n/src/messages/en.json | 2 + packages/i18n/src/messages/es.json | 2 + packages/i18n/src/messages/fr.json | 2 + packages/i18n/src/messages/pt.json | 2 + .../src/actions/notify-chat-mention.test.ts | 44 +++++++++++++++++++ .../src/actions/notify-chat-mention.ts | 28 ++---------- .../src/actions/notify-chat-mention.utils.ts | 23 ++++++++++ 10 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 packages/notifications/src/actions/notify-chat-mention.test.ts create mode 100644 packages/notifications/src/actions/notify-chat-mention.utils.ts diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx index 257d875e1c..6d9625f417 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx @@ -157,6 +157,7 @@ type HumanChatPanelMessagesProps = { /** Scroll this Matrix event id into view when set (e.g. mention inbox). */ scrollTargetEventId?: string | null; onConsumedScrollTarget?: () => void; + onScrollTargetNotFound?: (eventId: string) => void; }; export function HumanChatPanelMessages({ @@ -180,6 +181,7 @@ export function HumanChatPanelMessages({ onMarkAsReadFromBanner, scrollTargetEventId, onConsumedScrollTarget, + onScrollTargetNotFound, }: HumanChatPanelMessagesProps) { const t = useTranslations('HumanChatPanel'); const formatter = useFormatter(); @@ -200,6 +202,9 @@ export function HumanChatPanelMessages({ const bottomSentinelRef = useRef(null); /** Avoid calling onConsumedScrollTarget repeatedly while scrollTargetEventId is unchanged. */ const scrollTargetConsumedRef = useRef(null); + /** Retry deep-link target lookup across timeline growth before falling back. */ + const scrollTargetMissCountRef = useRef(0); + const scrollTargetMissHandledRef = useRef(null); /** At most one floating action bar: pointer hover, or locked while that row's hover emoji picker is open. */ const [hoverActionMessageId, setHoverActionMessageId] = useState< @@ -271,6 +276,13 @@ export function HumanChatPanelMessages({ useEffect(() => { if (!scrollTargetEventId) { scrollTargetConsumedRef.current = null; + scrollTargetMissCountRef.current = 0; + scrollTargetMissHandledRef.current = null; + return; + } + if (scrollTargetMissHandledRef.current !== scrollTargetEventId) { + scrollTargetMissCountRef.current = 0; + scrollTargetMissHandledRef.current = null; } }, [scrollTargetEventId]); @@ -330,9 +342,26 @@ export function HumanChatPanelMessages({ row.scrollIntoView({ block: 'center', behavior: 'smooth' }); stickToBottomRef.current = false; scrollTargetConsumedRef.current = scrollTargetEventId; + scrollTargetMissCountRef.current = 0; + scrollTargetMissHandledRef.current = null; onConsumedScrollTarget?.(); + return; + } + + scrollTargetMissCountRef.current += 1; + if ( + scrollTargetMissCountRef.current >= 12 && + scrollTargetMissHandledRef.current !== scrollTargetEventId + ) { + scrollTargetMissHandledRef.current = scrollTargetEventId; + onScrollTargetNotFound?.(scrollTargetEventId); } - }, [scrollTargetEventId, onConsumedScrollTarget, timelineRows.length]); + }, [ + scrollTargetEventId, + onConsumedScrollTarget, + onScrollTargetNotFound, + timelineRows.length, + ]); useEffect(() => { const root = containerRef.current; diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx index 9ad0b0a765..9ca1f92e85 100644 --- a/packages/epics/src/common/human-right-panel.tsx +++ b/packages/epics/src/common/human-right-panel.tsx @@ -659,6 +659,9 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { const [deleteError, setDeleteError] = useState(null); const [activeTab, setActiveTab] = useState('chat'); const [scrollToEventId, setScrollToEventId] = useState(null); + const [mentionNavigationNotice, setMentionNavigationNotice] = useState< + string | null + >(null); /** Shown in timeline after a short delay while large attachment sends run. */ const [sendingPending, setSendingPending] = useState { + setMentionNavigationNotice(null); const targetRoom = fromRoomId?.trim(); const current = roomId?.trim(); const langMatch = pathname.match(/^\/([^/]+)\//); @@ -1794,6 +1798,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { setActiveTab('chat'); return; } + setMentionNavigationNotice(t('mentionOpenFallbackRoom')); } setActiveTab('chat'); @@ -1806,6 +1811,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { openHumanChatPanel, space?.chatRoomId, spaceSlug, + t, ], ); @@ -1813,6 +1819,14 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { setScrollToEventId(null); }, []); + const handleScrollTargetNotFound = useCallback( + (_eventId: string) => { + setMentionNavigationNotice(t('mentionOpenFallbackMissingMessage')); + setScrollToEventId(null); + }, + [t], + ); + const mergedMessages = useMemo(() => { if (!sendingPending) return messages; const pendingRow: UIMessage = { @@ -2592,6 +2606,14 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { {composerError}

)} + {mentionNavigationNotice && ( +
+ {mentionNavigationNotice} +
+ )} {reactionError && (
)}
diff --git a/packages/i18n/src/messages/de.json b/packages/i18n/src/messages/de.json index 8de81902d2..067176981f 100644 --- a/packages/i18n/src/messages/de.json +++ b/packages/i18n/src/messages/de.json @@ -2000,6 +2000,8 @@ "mentionInboxNotificationSettings": "Benachrichtigungseinstellungen", "mentionInboxEmpty": "Noch keine Erwähnungen in diesem Chat.", "mentionInboxNoPreview": "(keine Vorschau)", + "mentionOpenFallbackRoom": "Dieser Raum konnte im lokalen Kontext nicht aufgelöst werden. Öffnen Sie den zugehörigen Space und versuchen Sie es erneut.", + "mentionOpenFallbackMissingMessage": "Diese Erwähnung ist in der geladenen Timeline noch nicht verfügbar. Bitte versuchen Sie es gleich noch einmal.", "mentionInboxBellAria": "{count, plural, one {# ungelesene Erwähnung} other {# ungelesene Erwähnungen}}", "mentionInboxBellAriaCapped": "99 oder mehr ungelesene Erwähnungen", "mentionInboxBellAriaEmpty": "Erwähnungen öffnen", diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index 3c0cbbdfc2..dd6ec94943 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -2000,6 +2000,8 @@ "mentionInboxNotificationSettings": "Notification settings", "mentionInboxEmpty": "No mentions in this chat yet.", "mentionInboxNoPreview": "(no preview)", + "mentionOpenFallbackRoom": "We could not resolve that room from your local context. Open the related space and try again.", + "mentionOpenFallbackMissingMessage": "That mention message is not available in the loaded timeline yet. Try again in a moment.", "mentionInboxBellAria": "{count, plural, one {# unread mention} other {# unread mentions}}", "mentionInboxBellAriaCapped": "99 or more unread mentions", "mentionInboxBellAriaEmpty": "Open mentions", diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 9cd8485820..fbc6524890 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1992,6 +1992,8 @@ "mentionInboxNotificationSettings": "Ajustes de notificaciones", "mentionInboxEmpty": "Aún no hay menciones en este chat.", "mentionInboxNoPreview": "(sin vista previa)", + "mentionOpenFallbackRoom": "No pudimos resolver esa sala desde tu contexto local. Abre el espacio correspondiente e inténtalo de nuevo.", + "mentionOpenFallbackMissingMessage": "Ese mensaje de mención aún no está disponible en la cronología cargada. Vuelve a intentarlo en un momento.", "mentionInboxBellAria": "{count, plural, one {# mención sin leer} other {# menciones sin leer}}", "mentionInboxBellAriaCapped": "99 o más menciones sin leer", "mentionInboxBellAriaEmpty": "Abrir menciones", diff --git a/packages/i18n/src/messages/fr.json b/packages/i18n/src/messages/fr.json index 16fc1cbf6e..a717b78b13 100644 --- a/packages/i18n/src/messages/fr.json +++ b/packages/i18n/src/messages/fr.json @@ -1992,6 +1992,8 @@ "mentionInboxNotificationSettings": "Paramètres de notification", "mentionInboxEmpty": "Aucune mention dans ce chat pour l’instant.", "mentionInboxNoPreview": "(aucun aperçu)", + "mentionOpenFallbackRoom": "Impossible de résoudre ce salon depuis votre contexte local. Ouvrez l'espace concerné puis réessayez.", + "mentionOpenFallbackMissingMessage": "Ce message de mention n'est pas encore disponible dans la chronologie chargée. Réessayez dans un instant.", "mentionInboxBellAria": "{count, plural, one {# mention non lue} other {# mentions non lues}}", "mentionInboxBellAriaCapped": "99 mentions non lues ou plus", "mentionInboxBellAriaEmpty": "Ouvrir les mentions", diff --git a/packages/i18n/src/messages/pt.json b/packages/i18n/src/messages/pt.json index 08386debef..8ce0823e0e 100644 --- a/packages/i18n/src/messages/pt.json +++ b/packages/i18n/src/messages/pt.json @@ -1992,6 +1992,8 @@ "mentionInboxNotificationSettings": "Configurações de notificação", "mentionInboxEmpty": "Ainda não há menções neste chat.", "mentionInboxNoPreview": "(sem pré-visualização)", + "mentionOpenFallbackRoom": "Não foi possível resolver essa sala no seu contexto local. Abra o espaço relacionado e tente novamente.", + "mentionOpenFallbackMissingMessage": "Essa mensagem de menção ainda não está disponível na linha do tempo carregada. Tente novamente em instantes.", "mentionInboxBellAria": "{count, plural, one {# menção não lida} other {# menções não lidas}}", "mentionInboxBellAriaCapped": "99 ou mais menções não lidas", "mentionInboxBellAriaEmpty": "Abrir menções", diff --git a/packages/notifications/src/actions/notify-chat-mention.test.ts b/packages/notifications/src/actions/notify-chat-mention.test.ts new file mode 100644 index 0000000000..407bca5c50 --- /dev/null +++ b/packages/notifications/src/actions/notify-chat-mention.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildMentionEmailBody, + sanitizeMentionIds, +} from './notify-chat-mention.utils'; + +describe('sanitizeMentionIds', () => { + it('deduplicates and trims matrix ids', () => { + expect( + sanitizeMentionIds([ + ' @alice:matrix.org ', + '@alice:matrix.org', + '', + ' ', + '@bob:matrix.org', + ]), + ).toEqual(['@alice:matrix.org', '@bob:matrix.org']); + }); +}); + +describe('buildMentionEmailBody', () => { + it('escapes html-sensitive content', () => { + const html = buildMentionEmailBody({ + actorDisplayName: 'Alice