From d5bcbe9856f167a27b99b220257b57f972327de5 Mon Sep 17 00:00:00 2001 From: Axel Krapotke Date: Mon, 23 Mar 2026 19:18:58 +0100 Subject: [PATCH] feat: invitation badge on sidebar scope icon (#invited) Show a numeric badge (1-99, then 99+) on the cloud-plus icon in the sidebar when there are pending layer invitations. Badge disappears when count is zero. - New useInvitationCount() hook tracks invited:* keys in the store - Re-counts on batch events that touch invited keys - Badge styled as pill overlay (top-right corner, ODIN accent color) - Only the @invited scope switch receives the badge prop --- src/renderer/components/hooks.js | 25 +++++++++++++++++++ .../components/sidebar/ScopeSwitcher.css | 18 +++++++++++++ .../components/sidebar/ScopeSwitcher.js | 11 +++++++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/hooks.js b/src/renderer/components/hooks.js index 1fbcc970..814adf65 100644 --- a/src/renderer/components/hooks.js +++ b/src/renderer/components/hooks.js @@ -108,3 +108,28 @@ export const useEmitter = key => { return services.emitters[key] }, [key, services]) } + +/** + * Track the number of pending invitations (invited:* keys in the store). + * Re-counts on every batch event that touches invited keys. + */ +export const useInvitationCount = () => { + const { store } = useServices() + const [count, setCount] = React.useState(0) + + React.useEffect(() => { + if (!store) return + + const recount = () => store.keys('invited').then(keys => setCount(keys.length)) + recount() + + const handler = ({ operations }) => { + if (operations.some(op => op.key && op.key.startsWith('invited:'))) recount() + } + + store.on('batch', handler) + return () => store.off('batch', handler) + }, [store]) + + return count +} diff --git a/src/renderer/components/sidebar/ScopeSwitcher.css b/src/renderer/components/sidebar/ScopeSwitcher.css index 92d1a0f8..32575c30 100644 --- a/src/renderer/components/sidebar/ScopeSwitcher.css +++ b/src/renderer/components/sidebar/ScopeSwitcher.css @@ -64,6 +64,24 @@ opacity: 1; } +.a74a-badge { + position: absolute; + top: 4px; + right: 4px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background: #e9746c; + color: white; + font-size: 10px; + font-weight: 600; + line-height: 16px; + text-align: center; + pointer-events: none; + box-sizing: border-box; +} + .a74a-breadcrumb { display: flex; flex-direction: column; diff --git a/src/renderer/components/sidebar/ScopeSwitcher.js b/src/renderer/components/sidebar/ScopeSwitcher.js index ce8642c3..1389c885 100644 --- a/src/renderer/components/sidebar/ScopeSwitcher.js +++ b/src/renderer/components/sidebar/ScopeSwitcher.js @@ -2,7 +2,7 @@ import * as R from 'ramda' import React from 'react' import PropTypes from 'prop-types' import * as mdi from '@mdi/js' -import { useMemento } from '../hooks' +import { useMemento, useInvitationCount } from '../hooks' import { defaultSearch } from './state' import * as ID from '../../ids' import { Tooltip } from 'react-tooltip' @@ -99,6 +99,11 @@ const ScopeSwitch = props => { <> + {props.badge > 0 && ( + + {props.badge > 99 ? '99+' : props.badge} + + )} @@ -112,6 +117,7 @@ ScopeSwitch.propTypes = { label: PropTypes.string.isRequired, scope: PropTypes.string.isRequired, toolTip: PropTypes.string, + badge: PropTypes.number, onScopeClick: PropTypes.func } @@ -120,12 +126,15 @@ ScopeSwitch.propTypes = { * Vertical column of scope icons */ export const ScopeSwitcher = ({ onScopeClick }) => { + const invitationCount = useInvitationCount() + const defaultSwitches = Object.entries(SCOPES).map(([scope, label]) => )