From 898d2ad717db3552b8408e57e594b89646bd02e6 Mon Sep 17 00:00:00 2001 From: Paulo Carinhena <63272749+paulocarinhena@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:32:05 -0300 Subject: [PATCH 1/2] =?UTF-8?q?Adiciona=20aba=20de=20Mensagens=20e=20suges?= =?UTF-8?q?t=C3=B5es=20de=20usu=C3=A1rio=20no=20convite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convites, notificações do GitHub e estrelas recebidas passam a ter uma aba própria, atualizada em segundo plano desde a abertura do programa, com aviso do sistema e contagem na aba. O topo desenha os últimos 30 dias como um céu de atividade; o convite aparece como um merge, que se completa ao aceitar. O campo de username dos colaboradores agora sugere contas do GitHub enquanto se digita, com nome e foto, para confirmar a pessoa antes de convidar. Co-Authored-By: Claude Opus 5.5 --- GitWeb/src/main/githubApi.ts | 189 +++++- GitWeb/src/main/ipc.ts | 36 +- GitWeb/src/preload/index.d.ts | 21 +- GitWeb/src/preload/index.ts | 22 +- GitWeb/src/renderer/src/App.tsx | 30 + GitWeb/src/renderer/src/browserApiStub.ts | 79 +++ .../renderer/src/components/ActivitySky.tsx | 163 +++++ GitWeb/src/renderer/src/components/TopBar.tsx | 10 +- .../src/components/UserSearchField.tsx | 183 ++++++ GitWeb/src/renderer/src/lib/activitySky.ts | 99 ++++ .../src/renderer/src/lib/messageTimeline.ts | 41 ++ GitWeb/src/renderer/src/store/inboxStore.ts | 197 +++++++ GitWeb/src/renderer/src/store/repoStore.ts | 2 +- GitWeb/src/renderer/src/styles/global.css | 558 ++++++++++++++++++ .../renderer/src/views/CollaboratorsView.tsx | 24 +- GitWeb/src/renderer/src/views/InitialView.tsx | 21 +- .../src/renderer/src/views/MessagesView.tsx | 459 ++++++++++++++ GitWeb/src/shared/github.ts | 49 ++ GitWeb/src/shared/i18n/index.ts | 2 + .../src/shared/i18n/messages/collaborators.ts | 21 +- GitWeb/src/shared/i18n/messages/inbox.ts | 216 +++++++ GitWeb/src/shared/i18n/messages/main.ts | 3 + GitWeb/test/collaborators.test.ts | 43 +- GitWeb/test/inbox.test.ts | 428 ++++++++++++++ 24 files changed, 2874 insertions(+), 22 deletions(-) create mode 100644 GitWeb/src/renderer/src/components/ActivitySky.tsx create mode 100644 GitWeb/src/renderer/src/components/UserSearchField.tsx create mode 100644 GitWeb/src/renderer/src/lib/activitySky.ts create mode 100644 GitWeb/src/renderer/src/lib/messageTimeline.ts create mode 100644 GitWeb/src/renderer/src/store/inboxStore.ts create mode 100644 GitWeb/src/renderer/src/views/MessagesView.tsx create mode 100644 GitWeb/src/shared/i18n/messages/inbox.ts create mode 100644 GitWeb/test/inbox.test.ts diff --git a/GitWeb/src/main/githubApi.ts b/GitWeb/src/main/githubApi.ts index 145503e..3942fcd 100644 --- a/GitWeb/src/main/githubApi.ts +++ b/GitWeb/src/main/githubApi.ts @@ -7,7 +7,9 @@ import type { BranchRules, CreatePullRequestInput, GitHubRepoSummary, + GitHubNotification, GitHubUser, + GitHubUserSuggestion, MergeMethod, PullRequest, PullRequestCheck, @@ -16,10 +18,12 @@ import type { PullRequestReviewSummary, PullRequestState, PullRequestStatus, + ReceivedInvitation, RepositoryCollaborators, RepositoryInvitation, RepositoryOwner, RepositoryRole, + RepositoryStar, ReviewDecision } from '../shared/github' @@ -100,7 +104,7 @@ async function send( } const next = nextPageUrl(response.headers.get('link')) - if (response.status === 204) return { data: undefined as T, next } + if (response.status === 204 || response.status === 205) return { data: undefined as T, next } return { data: (await response.json()) as T, next } } @@ -813,6 +817,27 @@ export async function getRepositoryCollaborators(host: string, owner: string, re } } +const USER_SEARCH_LIMIT = 8 + +/** + * Accounts matching a login or profile name. GraphQL because the REST search + * leaves the name out, and the name is how someone recognizes the right person. + */ +export async function searchUsers(host: string, query: string): Promise { + const token = await requireToken(host) + const { search } = await graphql<{ + search: { nodes: ({ login?: string; name?: string | null; avatarUrl?: string } | null)[] } + }>( + host, + token, + 'query($q: String!, $first: Int!) { search(query: $q, type: USER, first: $first) { nodes { ... on User { login name avatarUrl } } } }', + { q: `${query} type:user`, first: USER_SEARCH_LIMIT } + ) + return search.nodes.flatMap((node) => + node?.login ? [{ login: node.login, name: node.name || null, avatarUrl: node.avatarUrl ?? '' }] : [] + ) +} + /** * Adds a person, or changes the role of one already there — GitHub has a single * endpoint for both. Without a role GitHub applies its default, and on personal @@ -851,3 +876,165 @@ export async function cancelRepositoryInvitation(host: string, owner: string, re const token = await requireToken(host) await request(host, `${repositoryPath(owner, repo)}/invitations/${invitationId}`, token, { method: 'DELETE' }) } + +// --- What the Messages tab shows ------------------------------------------------ + +/** + * Runs a read that happens on every start, signed in or not: null means there is + * no working sign-in, which is not an error. Doesn't delete a dead token — + * getSession already owns that decision. + */ +async function whenSignedIn(host: string, read: (token: string) => Promise): Promise { + const token = await getSecret(credentialKey(host)) + if (!token) return null + try { + return await read(token) + } catch (err) { + if (err instanceof GitHubApiError && err.status === 401) return null + throw err + } +} + +interface ApiNotification { + id: string + reason: string + updated_at: string + subject: { title: string; type: string; url: string | null } + repository: { full_name: string; html_url: string } +} + +/** The page to open: GitHub only gives the API address of a subject, and not even that for some kinds. */ +function notificationWebUrl({ subject, repository }: ApiNotification): string { + const repo = repository.html_url + const match = subject.url?.match(/\/(pulls|issues|commits)\/([^/]+)$/) + if (match) { + const section = { pulls: 'pull', issues: 'issues', commits: 'commit' }[match[1]] + return `${repo}/${section}/${match[2]}` + } + if (subject.type === 'Release') return `${repo}/releases` + if (subject.type === 'Discussion') return `${repo}/discussions` + if (subject.type === 'CheckSuite') return `${repo}/actions` + return repo +} + +/** Unread notifications, newest first. Invitations are left out: they have their own list, with their own actions. */ +export function getNotifications(host: string): Promise { + return whenSignedIn(host, async (token) => { + const notifications = await request(host, '/notifications?per_page=50', token) + return notifications + .filter((notification) => notification.subject.type !== 'RepositoryInvitation') + .map((notification) => ({ + id: notification.id, + kind: notification.subject.type, + reason: notification.reason, + title: notification.subject.title, + repoFullName: notification.repository.full_name, + htmlUrl: notificationWebUrl(notification), + updatedAt: notification.updated_at + })) + }) +} + +export async function markNotificationRead(host: string, threadId: string): Promise { + const token = await requireToken(host) + await request(host, `/notifications/threads/${encodeURIComponent(threadId)}`, token, { method: 'PATCH' }) +} + +/** Up to `lastReadAt`, so something that arrives after the list was read stays unread. */ +export async function markAllNotificationsRead(host: string, lastReadAt: string): Promise { + const token = await requireToken(host) + await request(host, '/notifications', token, { method: 'PUT', body: JSON.stringify({ last_read_at: lastReadAt }) }) +} + +const RECENT_STARS_DAYS = 30 +const RECENT_STARS_LIMIT = 20 + +/** + * GitHub doesn't notify about stars, so they are read from the repositories + * themselves: the latest few of each is enough to catch up between checks. + * Costs a single GraphQL request however many repositories there are. + */ +export function getRecentStars(host: string): Promise { + return whenSignedIn(host, async (token) => { + const { viewer } = await graphql<{ + viewer: { + login: string + repositories: { + nodes: { + nameWithOwner: string + stargazers: { edges: { starredAt: string; node: { login: string; avatarUrl: string; url: string } }[] } + }[] + } + } + }>( + host, + token, + 'query { viewer { login repositories(first: 100, ownerAffiliations: [OWNER, ORGANIZATION_MEMBER], orderBy: { field: PUSHED_AT, direction: DESC }) { nodes { nameWithOwner stargazers(first: 5, orderBy: { field: STARRED_AT, direction: DESC }) { edges { starredAt node { login avatarUrl url } } } } } } }', + {} + ) + const since = Date.now() - RECENT_STARS_DAYS * 24 * 60 * 60 * 1000 + return viewer.repositories.nodes + .flatMap((repository) => + repository.stargazers.edges.map((edge) => ({ + repoFullName: repository.nameWithOwner, + login: edge.node.login, + avatarUrl: edge.node.avatarUrl, + profileUrl: edge.node.url, + starredAt: edge.starredAt + })) + ) + .filter((star) => star.login !== viewer.login && Date.parse(star.starredAt) >= since) + .sort((a, b) => Date.parse(b.starredAt) - Date.parse(a.starredAt)) + .slice(0, RECENT_STARS_LIMIT) + }) +} + +interface ApiReceivedInvitation { + id: number + html_url: string + permissions: string + created_at: string + expired?: boolean + repository: { full_name: string; private?: boolean } + inviter: { login: string; avatar_url?: string } | null +} + +/** Pending invitations. Expired ones are left out because GitHub no longer lets anyone act on them. */ +export function getReceivedInvitations(host: string): Promise { + return whenSignedIn(host, async (token) => { + const invitations = await requestAll(host, '/user/repository_invitations?per_page=100', token) + return invitations + .filter((invitation) => !invitation.expired) + .map((invitation) => ({ + id: invitation.id, + repoFullName: invitation.repository.full_name, + htmlUrl: invitation.html_url, + isPrivate: Boolean(invitation.repository.private), + inviterLogin: invitation.inviter?.login ?? null, + inviterAvatarUrl: invitation.inviter?.avatar_url ?? null, + role: invitation.permissions, + createdAt: invitation.created_at + })) + }) +} + +/** A 404 here means the invitation was cancelled, already answered, or expired since the list was read. */ +async function answerInvitation(host: string, invitationId: number, method: 'PATCH' | 'DELETE'): Promise { + const token = await requireToken(host) + try { + await request(host, `/user/repository_invitations/${invitationId}`, token, { method }) + } catch (err) { + if (err instanceof GitHubApiError && err.status === 404) { + throw new GitHubApiError(tMain().github.invitationGone, err.status) + } + throw err + } +} + +export function acceptInvitation(host: string, invitationId: number): Promise { + return answerInvitation(host, invitationId, 'PATCH') +} + +export function declineInvitation(host: string, invitationId: number): Promise { + return answerInvitation(host, invitationId, 'DELETE') +} diff --git a/GitWeb/src/main/ipc.ts b/GitWeb/src/main/ipc.ts index 1fe102d..ea555cc 100644 --- a/GitWeb/src/main/ipc.ts +++ b/GitWeb/src/main/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, app, BrowserWindow, type WebFrameMain } from 'electron' +import { ipcMain, app, BrowserWindow, Notification, type WebContents, type WebFrameMain } from 'electron' import { abortMerge, applyPatch, @@ -145,7 +145,33 @@ function handle(channel: string, listener: Parameters[1]) }) } +// Held until dismissed: Electron drops the click handler of a notification that gets garbage collected. +const liveNotifications = new Set() + +/** Lives here because the session's permission handler refuses the renderer's own Notification API. */ +function showDesktopNotification(sender: WebContents, title: string, body: string): void { + if (!Notification.isSupported()) return + const notification = new Notification({ title, body }) + liveNotifications.add(notification) + notification.on('close', () => liveNotifications.delete(notification)) + notification.on('failed', (_event, error) => { + liveNotifications.delete(notification) + console.error('Desktop notification failed:', error) + }) + notification.on('click', () => { + liveNotifications.delete(notification) + if (sender.isDestroyed()) return + const window = BrowserWindow.fromWebContents(sender) + if (window?.isMinimized()) window.restore() + window?.show() + window?.focus() + sender.send('app:notification-clicked') + }) + notification.show() +} + export function registerIpcHandlers(): void { + handle('app:showNotification', (event, title: string, body: string) => showDesktopNotification(event.sender, title, body)) handle('app:getVersion', () => app.getVersion()) handle('app:getUpdateStatus', () => getUpdateStatus()) handle('app:checkForUpdates', () => checkForUpdates()) @@ -353,6 +379,14 @@ export function registerIpcHandlers(): void { handle('github:getRepositoryCollaborators', (_e, host: string, owner: string, repo: string) => githubApi.getRepositoryCollaborators(host, owner, repo) ) + handle('github:getReceivedInvitations', (_e, host: string) => githubApi.getReceivedInvitations(host)) + handle('github:acceptInvitation', (_e, host: string, invitationId: number) => githubApi.acceptInvitation(host, invitationId)) + handle('github:declineInvitation', (_e, host: string, invitationId: number) => githubApi.declineInvitation(host, invitationId)) + handle('github:getNotifications', (_e, host: string) => githubApi.getNotifications(host)) + handle('github:markNotificationRead', (_e, host: string, threadId: string) => githubApi.markNotificationRead(host, threadId)) + handle('github:markAllNotificationsRead', (_e, host: string, lastReadAt: string) => githubApi.markAllNotificationsRead(host, lastReadAt)) + handle('github:getRecentStars', (_e, host: string) => githubApi.getRecentStars(host)) + handle('github:searchUsers', (_e, host: string, query: string) => githubApi.searchUsers(host, query)) handle('github:addCollaborator', (_e, host: string, owner: string, repo: string, username: string, role?: RepositoryRole) => githubApi.addCollaborator(host, owner, repo, username, role) ) diff --git a/GitWeb/src/preload/index.d.ts b/GitWeb/src/preload/index.d.ts index 02699f5..9f7dc61 100644 --- a/GitWeb/src/preload/index.d.ts +++ b/GitWeb/src/preload/index.d.ts @@ -53,9 +53,13 @@ import type { PublishRepositoryInput, PublishRepositoryResult, AddCollaboratorResult, + GitHubNotification, + GitHubUserSuggestion, + ReceivedInvitation, RepositoryCollaborators, RepositoryOwner, - RepositoryRole + RepositoryRole, + RepositoryStar } from '../shared/github' import type { UpdateStatus } from '../shared/updates' @@ -66,6 +70,9 @@ export interface Api { checkForUpdates: () => Promise quitAndInstall: () => Promise capturePage: (rect: { x: number; y: number; width: number; height: number }) => Promise + /** A system notification; clicking it brings the window forward and fires onNotificationClicked. */ + showNotification: (title: string, body: string) => Promise + onNotificationClicked: (callback: () => void) => () => void onUpdateStatus: (callback: (status: UpdateStatus) => void) => () => void } git: { @@ -198,6 +205,18 @@ export interface Api { listUserReposWithToken: (host: string, token: string) => Promise getRepositoryCollaborators: (host: string, owner: string, repo: string) => Promise + /** Invitations this account received; null when it isn't signed in on that host. */ + getReceivedInvitations: (host: string) => Promise + acceptInvitation: (host: string, invitationId: number) => Promise + declineInvitation: (host: string, invitationId: number) => Promise + /** Unread GitHub notifications; null when not signed in on that host. */ + getNotifications: (host: string) => Promise + markNotificationRead: (host: string, threadId: string) => Promise + markAllNotificationsRead: (host: string, lastReadAt: string) => Promise + /** Recent stars on this account's repositories; null when not signed in on that host. */ + getRecentStars: (host: string) => Promise + /** Accounts matching a login or profile name, for suggesting who to invite. */ + searchUsers: (host: string, query: string) => Promise /** Invites a person, or changes the role of one already there. */ addCollaborator: (host: string, owner: string, repo: string, username: string, role?: RepositoryRole) => Promise removeCollaborator: (host: string, owner: string, repo: string, username: string) => Promise diff --git a/GitWeb/src/preload/index.ts b/GitWeb/src/preload/index.ts index 516ccf7..456d678 100644 --- a/GitWeb/src/preload/index.ts +++ b/GitWeb/src/preload/index.ts @@ -54,9 +54,13 @@ import type { PublishRepositoryInput, PublishRepositoryResult, AddCollaboratorResult, + GitHubNotification, + GitHubUserSuggestion, + ReceivedInvitation, RepositoryCollaborators, RepositoryOwner, - RepositoryRole + RepositoryRole, + RepositoryStar } from '../shared/github' import type { UpdateStatus } from '../shared/updates' @@ -81,6 +85,14 @@ const api = { quitAndInstall: (): Promise => invoke('app:quitAndInstall'), capturePage: (rect: { x: number; y: number; width: number; height: number }): Promise => invoke('app:capturePage', rect), + showNotification: (title: string, body: string): Promise => invoke('app:showNotification', title, body), + onNotificationClicked: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on('app:notification-clicked', listener) + return () => { + ipcRenderer.removeListener('app:notification-clicked', listener) + } + }, onUpdateStatus: (callback: (status: UpdateStatus) => void): (() => void) => { const listener = (_event: IpcRendererEvent, status: UpdateStatus): void => { callback(status) @@ -281,6 +293,14 @@ const api = { getRepositoryCollaborators: (host: string, owner: string, repo: string): Promise => invoke('github:getRepositoryCollaborators', host, owner, repo), + getReceivedInvitations: (host: string): Promise => invoke('github:getReceivedInvitations', host), + acceptInvitation: (host: string, invitationId: number): Promise => invoke('github:acceptInvitation', host, invitationId), + declineInvitation: (host: string, invitationId: number): Promise => invoke('github:declineInvitation', host, invitationId), + getNotifications: (host: string): Promise => invoke('github:getNotifications', host), + markNotificationRead: (host: string, threadId: string): Promise => invoke('github:markNotificationRead', host, threadId), + markAllNotificationsRead: (host: string, lastReadAt: string): Promise => invoke('github:markAllNotificationsRead', host, lastReadAt), + getRecentStars: (host: string): Promise => invoke('github:getRecentStars', host), + searchUsers: (host: string, query: string): Promise => invoke('github:searchUsers', host, query), addCollaborator: (host: string, owner: string, repo: string, username: string, role?: RepositoryRole): Promise => invoke('github:addCollaborator', host, owner, repo, username, role), removeCollaborator: (host: string, owner: string, repo: string, username: string): Promise => diff --git a/GitWeb/src/renderer/src/App.tsx b/GitWeb/src/renderer/src/App.tsx index a5ada11..36c7dbd 100644 --- a/GitWeb/src/renderer/src/App.tsx +++ b/GitWeb/src/renderer/src/App.tsx @@ -4,6 +4,8 @@ import { useRepoStore } from './store/repoStore' import { usePullRequestStore } from './store/pullRequestStore' import { useAppSettingsStore } from './store/appSettingsStore' import { useAiStore } from './store/aiStore' +import { useGithubAuthStore } from './store/githubAuthStore' +import { useInboxStore } from './store/inboxStore' import { useResolvedTheme } from './lib/theme' import { COMPACT_SHELL_QUERY, useMediaQuery } from './lib/useMediaQuery' import InitialView from './views/InitialView' @@ -15,6 +17,7 @@ import StashesView from './views/StashesView' import PullRequestsView from './views/PullRequestsView' import CollaboratorsView from './views/CollaboratorsView' import DashboardView from './views/DashboardView' +import MessagesView from './views/MessagesView' import AiChatView from './views/AiChatView' import SettingsView from './views/SettingsView' import AboutView from './views/AboutView' @@ -60,6 +63,7 @@ function BranchStashBanner(): JSX.Element | null { const MIN_CHAT_WIDTH = AI_CHAT_MIN_WIDTH const MAX_CHAT_WIDTH = 720 const DEFAULT_CHAT_WIDTH = 400 +const INBOX_POLL_MS = 5 * 60 * 1000 function App(): JSX.Element { const currentRepoPath = useRepoStore((s) => s.currentRepoPath) @@ -241,12 +245,37 @@ function App(): JSX.Element { } }, [currentRepoPath]) + // Lives here, not in the Messages view, so the program checks and warns from + // the moment it starts: with no repository open, and on any tab. Signing in or + // out changes whose invitations these are, so that checks again too. + const prLogin = usePullRequestStore((s) => s.user?.login) + const startScreenLogin = useGithubAuthStore((s) => s.user?.login) + const refreshInbox = useInboxStore((s) => s.refresh) + useEffect(() => { + void refreshInbox() + }, [prLogin, startScreenLogin, refreshInbox]) + useEffect(() => { + const id = setInterval(() => void refreshInbox(), INBOX_POLL_MS) + const stopListening = window.api.app.onNotificationClicked(() => useRepoStore.getState().setView('messages')) + return () => { + clearInterval(id) + stopListening() + } + }, [refreshInbox]) + if (view === 'about') { return } if (!currentRepoPath) { if (view === 'settings') return + if (view === 'messages') { + return ( +
+ +
+ ) + } return ( <> @@ -313,6 +342,7 @@ function App(): JSX.Element { {view === 'pullRequests' && } {view === 'collaborators' && } {view === 'dashboard' && } + {view === 'messages' && } {view === 'settings' && } diff --git a/GitWeb/src/renderer/src/browserApiStub.ts b/GitWeb/src/renderer/src/browserApiStub.ts index c48bc01..b7a738a 100644 --- a/GitWeb/src/renderer/src/browserApiStub.ts +++ b/GitWeb/src/renderer/src/browserApiStub.ts @@ -80,6 +80,40 @@ function demoCollaboratorVariant(): string | null { } } +let demoReceivedInvitations = [ + { + id: 9001, + repoFullName: 'ana-souza/financeiro', + htmlUrl: 'https://github.com/ana-souza/financeiro/invitations', + isPrivate: true, + inviterLogin: 'ana-souza', + inviterAvatarUrl: null, + role: 'write', + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString() + } +] + +let demoNotifications = [ + { + id: '1', + kind: 'PullRequest', + reason: 'review_requested', + title: 'Corrige o cálculo de frete', + repoFullName: 'demo/gitweb', + htmlUrl: 'https://github.com/demo/gitweb/pull/42', + updatedAt: new Date(Date.now() - 40 * 60 * 1000).toISOString() + }, + { + id: '2', + kind: 'Issue', + reason: 'mention', + title: 'Erro ao abrir repositório grande', + repoFullName: 'demo/gitweb', + htmlUrl: 'https://github.com/demo/gitweb/issues/7', + updatedAt: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString() + } +] + let demoCollaborators = [ { login: 'demo', avatarUrl: '', role: 'admin', direct: true }, { login: 'demo-dev', avatarUrl: '', role: 'admin', direct: true }, @@ -331,6 +365,8 @@ function installBrowserApiStub(): void { capturePage: async () => { throw new Error('Captura de página indisponível no navegador.') }, + showNotification: async () => undefined, + onNotificationClicked: () => () => undefined, onUpdateStatus: () => () => undefined }, git: { @@ -563,6 +599,49 @@ function installBrowserApiStub(): void { invitationsError: null } }, + getReceivedInvitations: async () => (demoGithub() ? demoReceivedInvitations : null), + acceptInvitation: async (_host, id) => { + demoReceivedInvitations = demoReceivedInvitations.filter((i) => i.id !== id) + }, + declineInvitation: async (_host, id) => { + demoReceivedInvitations = demoReceivedInvitations.filter((i) => i.id !== id) + }, + getNotifications: async () => (demoGithub() ? demoNotifications : null), + markNotificationRead: async (_host, threadId) => { + demoNotifications = demoNotifications.filter((n) => n.id !== threadId) + }, + markAllNotificationsRead: async () => { + demoNotifications = [] + }, + getRecentStars: async () => + demoGithub() + ? ( + [ + ['ana-souza', 'demo/gitweb', 3], + ['bruno-lima', 'demo/gitweb', 28], + ['carla-dev', 'demo/api-pagamentos', 74], + ['dmitri-k', 'demo/gitweb', 150], + ['elisa-m', 'demo/site', 240], + ['fabio-r', 'demo/api-pagamentos', 410], + ['gabi-tech', 'demo/gitweb', 600] + ] as const + ).map(([login, repoFullName, hours]) => ({ + repoFullName, + login, + avatarUrl: '', + profileUrl: `https://github.com/${login}`, + starredAt: new Date(Date.now() - hours * 60 * 60 * 1000).toISOString() + })) + : null, + searchUsers: async (_host, query) => { + if (!demoGithub()) throw new Error(GITHUB_UNAVAILABLE) + const q = query.toLowerCase() + return [ + { login: 'demo-dev', name: 'Demo Dev', avatarUrl: '' }, + { login: 'ana-souza', name: 'Ana Souza', avatarUrl: '' }, + { login: 'anabeatriz', name: null, avatarUrl: '' } + ].filter((u) => u.login.includes(q) || u.name?.toLowerCase().includes(q)) + }, addCollaborator: async (_host, _owner, _repo, username, role) => { if (!demoGithub()) throw new Error(GITHUB_UNAVAILABLE) if (username.toLowerCase() === 'ghost-user') throw new Error(`O GitHub não encontrou ${username}, ou sua conta não pode gerenciar este repositório.`) diff --git a/GitWeb/src/renderer/src/components/ActivitySky.tsx b/GitWeb/src/renderer/src/components/ActivitySky.tsx new file mode 100644 index 0000000..6312bb7 --- /dev/null +++ b/GitWeb/src/renderer/src/components/ActivitySky.tsx @@ -0,0 +1,163 @@ +import { useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { Avatar } from '../views/PullRequestsView' +import { useLocale, useT } from '../lib/i18n' +import { relativeTime } from '../lib/relativeTime' +import { layoutSky, skyDust, type SkyPoint } from '../lib/activitySky' +import type { TimelineItem } from '../lib/messageTimeline' +import { isNewStar } from '../store/inboxStore' + +const HEIGHT = 340 +const DUST = skyDust(90) +const TWINKLING = DUST.filter((_, index) => index % 11 === 0) +const DAY = 24 * 60 * 60 * 1000 +const STAR_SHAPE = 'M0 -10 Q1.1 -1.1 10 0 Q1.1 1.1 0 10 Q-1.1 1.1 -10 0 Q-1.1 -1.1 0 -10Z' + +interface ActivitySkyProps { + items: TimelineItem[] + needsYou: (reason: string) => boolean + newStarsSince: number + activeKey: string | null + onActivate: (key: string | null) => void + /** The page's title and refresh control, which sit over the sky. */ + children: ReactNode +} + +function pointClass(point: SkyPoint, newStarsSince: number, needsYou: (reason: string) => boolean): string { + if (point.item.type === 'star') return isNewStar(point.item.star, newStarsSince) ? 'sky-star is-new' : 'sky-star' + return needsYou(point.item.notification.reason) ? 'sky-event is-attention' : 'sky-event' +} + +/** + * The account's last 30 days as a night sky: repositories as lanes, stars as + * stars, and a single line through all of it reaching the present. It duplicates + * the list below on purpose, as a picture, so it is hidden from screen readers + * and every point can be reached there instead. + */ +function ActivitySky({ items, needsYou, newStarsSince, activeKey, onActivate, children }: ActivitySkyProps): JSX.Element { + const ref = useRef(null) + const [width, setWidth] = useState(0) + const t = useT().inbox + const locale = useLocale() + + useLayoutEffect(() => { + const element = ref.current + if (!element) return + setWidth(element.clientWidth) + const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width)) + observer.observe(element) + return () => observer.disconnect() + }, []) + + const sky = useMemo(() => layoutSky(items, width, HEIGHT), [items, width]) + const active = sky.points.find((point) => point.key === activeKey) + const now = Date.now() + + return ( +
+ {width > 0 && ( + + + + + + + + + + + {DUST.map((dot, index) => ( + + ))} + + + {sky.points.length > 0 && ( + <> + {sky.ticks.map(({ x, days }) => ( + + + + {relativeTime(locale, new Date(now - days * DAY).toISOString(), now)} + + + ))} + {sky.lanes.map(({ repo, y }) => ( + + + + {repo.split('/').pop()} + + + ))} + + {sky.points.map((point, index) => ( + onActivate(point.key)} + onMouseLeave={() => onActivate(null)} + > + {/* Larger than the mark, so a small point is still easy to land on. */} + + + + {point.item.type === 'star' ? : } + + + ))} + + + + {t.skyNow} + + + + )} + + )} + + {/* Everything that keeps moving lives out here as its own layer: animating + inside the SVG would repaint all of it, filters included, on every frame. */} + {width > 0 && ( +
+ {TWINKLING.map((dot, index) => ( + + ))} + {sky.points.map( + (point, index) => + point.item.type === 'star' && + isNewStar(point.item.star, newStarsSince) && + )} + {sky.points.length > 0 && } +
+ )} + + {active && ( +
width - 260 ? 'is-flipped' : ''}`} style={{ left: active.x, top: active.y }}> + {active.item.type === 'star' ? ( + <> + + + {active.item.star.login} {t.starredVerb} + + {active.item.star.repoFullName} · {relativeTime(locale, active.item.star.starredAt, now)} + + + + ) : ( + + {active.item.notification.title} + + {active.item.notification.repoFullName} · {relativeTime(locale, active.item.notification.updatedAt, now)} + + + )} +
+ )} + +
{children}
+
+ ) +} + +export default ActivitySky diff --git a/GitWeb/src/renderer/src/components/TopBar.tsx b/GitWeb/src/renderer/src/components/TopBar.tsx index d08fcae..b3bc212 100644 --- a/GitWeb/src/renderer/src/components/TopBar.tsx +++ b/GitWeb/src/renderer/src/components/TopBar.tsx @@ -12,6 +12,7 @@ import { GitBranch, GitMerge, GitPullRequest, + Inbox, LayoutDashboard, Loader2, Pencil, @@ -31,6 +32,7 @@ import { NARROW_LAYOUT_QUERY, useMediaQuery } from '../lib/useMediaQuery' import { updateStatusLabel, useUpdateStatus } from '../lib/useUpdateStatus' import { useRepoStore } from '../store/repoStore' import { useGithubAuthStore } from '../store/githubAuthStore' +import { pendingMessageCount, useInboxStore } from '../store/inboxStore' import ConfirmDialog, { PromptDialog } from './ConfirmDialog' import Github from './icons/Github' import { openSettings } from '../lib/openAiSettings' @@ -737,15 +739,18 @@ function TopBar(): JSX.Element { const setView = useRepoStore((s) => s.setView) const closeRepository = useRepoStore((s) => s.closeRepository) const t = useT().topBar + const messagesLabel = useT().inbox.title + const pendingMessages = useInboxStore(pendingMessageCount) const narrow = useMediaQuery(NARROW_LAYOUT_QUERY) - const tabs = [ + const tabs: { id: typeof view; label: string; icon: typeof Inbox; count?: number }[] = [ { id: 'changes' as const, label: t.tabs.changes, icon: FileDiff }, { id: 'history' as const, label: t.tabs.history, icon: Clock }, { id: 'stash' as const, label: t.tabs.stash, icon: Archive }, { id: 'pullRequests' as const, label: t.tabs.pullRequests, icon: GitPullRequest }, { id: 'collaborators' as const, label: t.tabs.collaborators, icon: Users }, { id: 'dashboard' as const, label: t.tabs.dashboard, icon: LayoutDashboard }, + { id: 'messages' as const, label: messagesLabel, icon: Inbox, count: pendingMessages }, { id: 'settings' as const, label: t.tabs.settings, icon: Settings2 } ] @@ -795,7 +800,7 @@ function TopBar(): JSX.Element { type="button" onClick={() => setView(tab.id)} title={tab.label} - aria-label={tab.label} + aria-label={tab.count ? `${tab.label} (${tab.count})` : tab.label} aria-current={active ? 'page' : undefined} className={ active @@ -805,6 +810,7 @@ function TopBar(): JSX.Element { > {!narrow && tab.label} + {Boolean(tab.count) && {tab.count}} ) })} diff --git a/GitWeb/src/renderer/src/components/UserSearchField.tsx b/GitWeb/src/renderer/src/components/UserSearchField.tsx new file mode 100644 index 0000000..331555f --- /dev/null +++ b/GitWeb/src/renderer/src/components/UserSearchField.tsx @@ -0,0 +1,183 @@ +import { useEffect, useId, useRef, useState } from 'react' +import { Loader2 } from 'lucide-react' +import { Avatar } from '../views/PullRequestsView' +import { useClickOutside } from '../lib/useClickOutside' +import { useT } from '../lib/i18n' +import { userSearchQuery, type GitHubUserSuggestion } from '../../../shared/github' + +// GitHub allows 30 searches a minute on REST and bills GraphQL by the point, so +// one per pause in typing, not one per key. +const SEARCH_DELAY_MS = 300 +const MIN_QUERY_LENGTH = 2 +const AVATAR_SIZE = 24 + +interface UserSearchFieldProps { + id: string + host: string + value: string + onChange: (value: string) => void + /** A short note about someone in the results, such as already having access. */ + statusOf: (login: string) => string | null + invalid?: boolean + describedBy?: string + placeholder?: string +} + +/** + * A username field that suggests GitHub accounts while typing. Choosing one only + * fills the field: inviting stays a separate, deliberate step, and a username + * the search doesn't find can still be typed in full. + */ +function UserSearchField({ id, host, value, onChange, statusOf, invalid, describedBy, placeholder }: UserSearchFieldProps): JSX.Element { + const t = useT().collaborators + const listboxId = useId() + const optionIdPrefix = useId() + const ref = useRef(null) + const [open, setOpen] = useState(false) + const [results, setResults] = useState([]) + const [isSearching, setIsSearching] = useState(false) + const [failed, setFailed] = useState(false) + // -1 until the user moves into the list, so Enter on a typed username submits the form. + const [highlighted, setHighlighted] = useState(-1) + useClickOutside(ref, () => setOpen(false)) + + const query = userSearchQuery(value) + const searchable = query.length >= MIN_QUERY_LENGTH + const showList = open && searchable + + useEffect(() => { + setHighlighted(-1) + if (!open || !searchable) { + setResults([]) + setIsSearching(false) + setFailed(false) + return + } + let stale = false + setIsSearching(true) + const timer = setTimeout(() => { + window.api.github + .searchUsers(host, query) + .then((users) => { + if (stale) return + setResults(users) + setFailed(false) + }) + .catch(() => { + if (stale) return + setResults([]) + setFailed(true) + }) + .finally(() => { + if (!stale) setIsSearching(false) + }) + }, SEARCH_DELAY_MS) + return () => { + stale = true + clearTimeout(timer) + } + }, [host, open, query, searchable]) + + function choose(user: GitHubUserSuggestion): void { + onChange(user.login) + setOpen(false) + } + + function handleKeyDown(e: React.KeyboardEvent): void { + if (e.key === 'ArrowDown') { + e.preventDefault() + if (!open) setOpen(true) + else setHighlighted((h) => Math.min(h + 1, results.length - 1)) + } else if (e.key === 'ArrowUp' && showList) { + e.preventDefault() + setHighlighted((h) => Math.max(h - 1, -1)) + } else if (e.key === 'Escape' && showList) { + e.preventDefault() + e.stopPropagation() + setOpen(false) + } else if (e.key === 'Tab') { + setOpen(false) + } else if (e.key === 'Enter') { + const user = showList ? results[highlighted] : undefined + if (user) { + e.preventDefault() + choose(user) + } else { + setOpen(false) + } + } + } + + return ( +
+ { + onChange(e.target.value) + setOpen(true) + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + autoComplete="off" + spellCheck={false} + className="field h-[34px] w-full py-0" + /> + + {showList && ( +
+
    + {results.map((user, index) => { + const status = statusOf(user.login) + const active = index === highlighted + return ( +
  • + +
  • + ) + })} +
+ {results.length === 0 && ( +

+ {isSearching ? ( + <> + + {t.searching} + + ) : failed ? ( + t.searchFailed + ) : ( + t.noMatches(query) + )} +

+ )} +
+ )} +
+ ) +} + +export default UserSearchField diff --git a/GitWeb/src/renderer/src/lib/activitySky.ts b/GitWeb/src/renderer/src/lib/activitySky.ts new file mode 100644 index 0000000..e2919e2 --- /dev/null +++ b/GitWeb/src/renderer/src/lib/activitySky.ts @@ -0,0 +1,99 @@ +import type { TimelineItem } from './messageTimeline' + +const DAY = 24 * 60 * 60 * 1000 +const WINDOW_DAYS = 30 +const MAX_LANES = 5 +const TICK_DAYS = [7, 14, 30] +// Below 1 stretches the recent end of the axis: today matters more than last month. +const TIME_CURVE = 0.55 +const LABEL_GUTTER = 172 +const RIGHT_GUTTER = 64 +const TOP = 118 +const BOTTOM_GUTTER = 100 +const MAX_LANE_GAP = 44 +const COLLISION_PX = 20 + +export interface SkyPoint { + key: string + x: number + y: number + item: TimelineItem +} + +export interface SkyLayout { + lanes: { repo: string; y: number }[] + points: SkyPoint[] + ticks: { x: number; days: number }[] + head: { x: number; y: number } + left: number + /** The line through everything, oldest to newest, ending at the present. */ + path: string +} + +function repoOf(item: TimelineItem): string { + return item.type === 'notification' ? item.notification.repoFullName : item.star.repoFullName +} + +/** + * Places the account's activity on a map: one lane per repository, time running + * left to right to the present. The busiest-lately repositories get a lane; the + * rest still appear in the list below, which is the complete record. + */ +export function layoutSky(items: TimelineItem[], width: number, height: number, now = Date.now()): SkyLayout { + const left = LABEL_GUTTER + const right = Math.max(left + 40, width - RIGHT_GUTTER) + const xForAge = (age: number): number => right - (right - left) * Math.pow(Math.min(Math.max(age, 0) / (WINDOW_DAYS * DAY), 1), TIME_CURVE) + + const newestFirst = [...items].sort((a, b) => Date.parse(b.at) - Date.parse(a.at)) + const repos = [...new Set(newestFirst.map(repoOf))].slice(0, MAX_LANES) + const available = Math.max(0, height - BOTTOM_GUTTER - TOP) + const gap = repos.length > 1 ? Math.min(MAX_LANE_GAP, available / (repos.length - 1)) : 0 + const spread = gap * Math.max(0, repos.length - 1) + const firstY = TOP + (available - spread) / 2 + const lanes = repos.map((repo, index) => ({ repo, y: firstY + index * gap })) + const laneY = new Map(lanes.map((lane) => [lane.repo, lane.y])) + + const points: SkyPoint[] = [] + for (const item of newestFirst) { + const y = laneY.get(repoOf(item)) + if (y === undefined) continue + const x = xForAge(now - Date.parse(item.at)) + // Things that happened together fan out instead of landing on top of each other. + const crowd = points.filter((point) => Math.abs(point.x - x) < COLLISION_PX && Math.abs(point.y - y) < MAX_LANE_GAP / 2).length + const nudge = crowd === 0 ? 0 : (crowd % 2 === 1 ? -1 : 1) * Math.ceil(crowd / 2) * 13 + points.push({ key: item.key, x, y: y + nudge, item }) + } + + const head = { x: right + 26, y: points[0]?.y ?? firstY } + const oldestFirst = [...points].reverse() + let path = '' + if (oldestFirst.length > 0) { + const route = [...oldestFirst.map(({ x, y }) => ({ x, y })), head] + path = `M${left - 8} ${route[0].y} L${route[0].x} ${route[0].y}` + for (let i = 1; i < route.length; i++) { + const from = route[i - 1] + const to = route[i] + const middle = (from.x + to.x) / 2 + path += ` C${middle} ${from.y} ${middle} ${to.y} ${to.x} ${to.y}` + } + } + + return { + lanes, + points, + ticks: TICK_DAYS.map((days) => ({ x: xForAge(days * DAY), days })), + head, + left, + path + } +} + +/** The same scatter on every render, so the background never jumps. */ +export function skyDust(count: number): { x: number; y: number; r: number; o: number }[] { + let seed = 48271 + const next = (): number => { + seed = (seed * 16807) % 2147483647 + return seed / 2147483647 + } + return Array.from({ length: count }, () => ({ x: next(), y: next(), r: 0.4 + next() * 0.9, o: 0.12 + next() * 0.5 })) +} diff --git a/GitWeb/src/renderer/src/lib/messageTimeline.ts b/GitWeb/src/renderer/src/lib/messageTimeline.ts new file mode 100644 index 0000000..cb6a62c --- /dev/null +++ b/GitWeb/src/renderer/src/lib/messageTimeline.ts @@ -0,0 +1,41 @@ +import type { GitHubNotification, RepositoryStar } from '../../../shared/github' + +export type TimelineItem = + | { type: 'notification'; at: string; key: string; notification: GitHubNotification } + | { type: 'star'; at: string; key: string; star: RepositoryStar } + +export type TimelineBucket = 'today' | 'yesterday' | 'week' | 'earlier' + +export interface TimelineGroup { + bucket: TimelineBucket + items: TimelineItem[] +} + +const BUCKETS: TimelineBucket[] = ['today', 'yesterday', 'week', 'earlier'] + +function bucketOf(iso: string, now: Date): TimelineBucket { + const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() + const time = Date.parse(iso) + const day = 24 * 60 * 60 * 1000 + if (time >= midnight) return 'today' + if (time >= midnight - day) return 'yesterday' + if (time >= midnight - 6 * day) return 'week' + return 'earlier' +} + +/** Everything that happened, newest first, under the day it happened on the person's own clock. */ +export function buildTimeline(notifications: GitHubNotification[], stars: RepositoryStar[], now = new Date()): TimelineGroup[] { + const items: TimelineItem[] = [ + ...notifications.map((notification) => ({ + type: 'notification' as const, + at: notification.updatedAt, + key: `notification:${notification.id}`, + notification + })), + ...stars.map((star) => ({ type: 'star' as const, at: star.starredAt, key: `star:${star.repoFullName}:${star.login}`, star })) + ].sort((a, b) => Date.parse(b.at) - Date.parse(a.at)) + + return BUCKETS.map((bucket) => ({ bucket, items: items.filter((item) => bucketOf(item.at, now) === bucket) })).filter( + (group) => group.items.length > 0 + ) +} diff --git a/GitWeb/src/renderer/src/store/inboxStore.ts b/GitWeb/src/renderer/src/store/inboxStore.ts new file mode 100644 index 0000000..f4ec614 --- /dev/null +++ b/GitWeb/src/renderer/src/store/inboxStore.ts @@ -0,0 +1,197 @@ +import { create } from 'zustand' +import type { GitHubNotification, ReceivedInvitation, RepositoryStar } from '../../../shared/github' +import { getT } from '../lib/i18n' + +// Everything here belongs to an account rather than a repository, so this +// follows the same default host the start screen signs in to. +const HOST = 'github.com' + +const STARS_SEEN_KEY = 'inbox.starsSeenAt' +// GitHub doesn't track stars as read, so "new" has to be remembered here. The +// first time, only the last few days count, not every star in the recent list. +const FIRST_RUN_NEW_STARS_DAYS = 7 + +function readStarsSeenAt(): number { + const stored = Number(localStorage.getItem(STARS_SEEN_KEY)) + if (stored) return stored + const baseline = Date.now() - FIRST_RUN_NEW_STARS_DAYS * 24 * 60 * 60 * 1000 + localStorage.setItem(STARS_SEEN_KEY, String(baseline)) + return baseline +} + +export function isNewStar(star: RepositoryStar, seenAt: number): boolean { + return Date.parse(star.starredAt) > seenAt +} + +interface InboxStoreState { + invitations: ReceivedInvitation[] + notifications: GitHubNotification[] + stars: RepositoryStar[] + starsSeenAt: number + /** Null until the first answer, so the view doesn't claim "no messages" before it knows. */ + signedIn: boolean | null + isLoading: boolean + /** The invitation being accepted or declined. */ + answeringId: number | null + /** When a check last completed, so the tab can show the background work is alive. */ + checkedAt: number | null + error: string | null + notice: string | null + + refresh: () => Promise + accept: (invitation: ReceivedInvitation) => Promise + decline: (invitation: ReceivedInvitation) => Promise + markRead: (id: string) => Promise + markAllRead: () => Promise + markStarsSeen: () => void + clearNotice: () => void +} + +/** What the count on the tab shows: everything still waiting for this person. */ +export function pendingMessageCount(state: Pick): number { + return state.invitations.length + state.notifications.length + state.stars.filter((star) => isNewStar(star, state.starsSeenAt)).length +} + +function describeError(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +// Kept per run on purpose: something still waiting is announced again the next +// time the program starts, but not on every background check in between. A +// notification's key includes its date, so new activity on it counts as news. +const announced = new Set() + +function announce({ invitations, notifications, stars, starsSeenAt }: InboxStoreState): void { + const t = getT().inbox + const candidates = [ + ...invitations.map((i) => ({ + key: `invitation:${i.id}`, + repo: i.repoFullName, + title: t.notificationInviteTitle, + body: t.notificationInviteBody(i.inviterLogin ?? t.someone, i.repoFullName) + })), + ...notifications.map((n) => ({ + key: `notification:${n.id}:${n.updatedAt}`, + repo: n.repoFullName, + title: t.kinds[n.kind] ?? n.kind, + body: `${n.title} (${n.repoFullName})` + })), + ...stars + .filter((star) => isNewStar(star, starsSeenAt)) + .map((star) => ({ + key: `star:${star.repoFullName}:${star.login}`, + repo: star.repoFullName, + title: t.notificationStarTitle, + body: t.notificationStarBody(star.login, star.repoFullName) + })) + ] + const fresh = candidates.filter((item) => !announced.has(item.key)) + if (fresh.length === 0) return + fresh.forEach((item) => announced.add(item.key)) + const notification = + fresh.length === 1 + ? window.api.app.showNotification(fresh[0].title, fresh[0].body) + : window.api.app.showNotification(t.notificationManyTitle(fresh.length), [...new Set(fresh.map((item) => item.repo))].slice(0, 3).join(', ')) + // The tab's count still shows them, so a refused notification costs nothing more than a log line. + notification.catch((err: unknown) => console.error('Could not show the notification:', err)) +} + +// A slower answer must not bring back something answered after it was read. +let loadGeneration = 0 + +export const useInboxStore = create((set, get) => { + async function answer( + invitation: ReceivedInvitation, + call: (host: string, id: number) => Promise, + notice: (repo: string) => string + ): Promise { + if (get().answeringId !== null) return + loadGeneration++ + set({ answeringId: invitation.id, isLoading: false, error: null, notice: null }) + try { + await call(HOST, invitation.id) + set((state) => ({ + invitations: state.invitations.filter((i) => i.id !== invitation.id), + notice: notice(invitation.repoFullName) + })) + } catch (err) { + set({ error: describeError(err) }) + } finally { + set({ answeringId: null }) + } + } + + return { + invitations: [], + notifications: [], + stars: [], + starsSeenAt: readStarsSeenAt(), + signedIn: null, + isLoading: false, + answeringId: null, + checkedAt: null, + error: null, + notice: null, + + refresh: async () => { + if (get().answeringId !== null) return + const generation = ++loadGeneration + set({ isLoading: true }) + // Each source can fail alone: stars being unavailable shouldn't hide an invitation. + const [invitations, notifications, stars] = await Promise.allSettled([ + window.api.github.getReceivedInvitations(HOST), + window.api.github.getNotifications(HOST), + window.api.github.getRecentStars(HOST) + ]) + if (generation !== loadGeneration) return + const signedOut = invitations.status === 'fulfilled' && invitations.value === null + const failure = [invitations, notifications, stars].find((result) => result.status === 'rejected') + set((state) => ({ + invitations: invitations.status === 'fulfilled' ? (invitations.value ?? []) : state.invitations, + notifications: signedOut ? [] : notifications.status === 'fulfilled' ? (notifications.value ?? []) : state.notifications, + stars: signedOut ? [] : stars.status === 'fulfilled' ? (stars.value ?? []) : state.stars, + signedIn: invitations.status === 'fulfilled' ? !signedOut : state.signedIn, + // Offline at startup, say: what was on screen stays, and the next check tries again. + error: failure ? describeError(failure.reason) : null, + checkedAt: failure ? state.checkedAt : Date.now(), + isLoading: false + })) + announce(get()) + }, + + accept: (invitation) => answer(invitation, window.api.github.acceptInvitation, getT().inbox.accepted), + decline: (invitation) => answer(invitation, window.api.github.declineInvitation, getT().inbox.declined), + + markRead: async (id) => { + const previous = get().notifications + loadGeneration++ + set({ notifications: previous.filter((n) => n.id !== id), isLoading: false, error: null }) + try { + await window.api.github.markNotificationRead(HOST, id) + } catch (err) { + set({ notifications: previous, error: describeError(err) }) + } + }, + + markAllRead: async () => { + const previous = get().notifications + const [newest] = previous + if (!newest) return + loadGeneration++ + set({ notifications: [], isLoading: false, error: null }) + try { + await window.api.github.markAllNotificationsRead(HOST, newest.updatedAt) + } catch (err) { + set({ notifications: previous, error: describeError(err) }) + } + }, + + markStarsSeen: () => { + const now = Date.now() + localStorage.setItem(STARS_SEEN_KEY, String(now)) + set({ starsSeenAt: now }) + }, + + clearNotice: () => set({ notice: null }) + } +}) diff --git a/GitWeb/src/renderer/src/store/repoStore.ts b/GitWeb/src/renderer/src/store/repoStore.ts index 5edfb89..2adf762 100644 --- a/GitWeb/src/renderer/src/store/repoStore.ts +++ b/GitWeb/src/renderer/src/store/repoStore.ts @@ -29,7 +29,7 @@ import { directPushBlock } from '../lib/branchProtection' import { isInsidePath, joinPath, pathKey } from '../lib/path' import { extractHunkPatch, parseFileDiff } from '../../../shared/patch' -type ViewName = 'changes' | 'history' | 'stash' | 'pullRequests' | 'collaborators' | 'dashboard' | 'settings' | 'about' +type ViewName = 'changes' | 'history' | 'stash' | 'pullRequests' | 'collaborators' | 'dashboard' | 'messages' | 'settings' | 'about' interface SelectedFile { path: string diff --git a/GitWeb/src/renderer/src/styles/global.css b/GitWeb/src/renderer/src/styles/global.css index af49361..d3afd28 100644 --- a/GitWeb/src/renderer/src/styles/global.css +++ b/GitWeb/src/renderer/src/styles/global.css @@ -1122,6 +1122,9 @@ @apply inline-flex h-4 min-w-4 items-center justify-center rounded-pill bg-hover-overlay px-1 text-[10px] font-semibold tabular-nums text-secondary-text; letter-spacing: 0; } + .count-badge.is-attention { + @apply bg-command-blue-soft text-command-blue; + } .chip-command { @apply inline-flex items-center gap-1 rounded-pill border border-command-blue/20 bg-command-blue-soft px-2.5 py-0.5 text-label uppercase text-command-blue; @@ -2377,3 +2380,558 @@ clip-path: polygon(62% 0, 100% 0, 100% 100%, 38% 100%); } } + +/* ── Messages: the account's last 30 days as a night sky ─────────────────── */ +@keyframes sky-line-draw { + from { + stroke-dashoffset: 1; + } + to { + stroke-dashoffset: 0; + } +} +@keyframes sky-mark-in { + 0% { + opacity: 0; + transform: scale(0) rotate(-45deg); + } + 70% { + opacity: 1; + transform: scale(1.35) rotate(6deg); + } + 100% { + opacity: 1; + transform: scale(1) rotate(0deg); + } +} +@keyframes sky-twinkle { + 0%, + 100% { + opacity: 0.15; + } + 50% { + opacity: 0.85; + } +} +@keyframes sky-glint { + 0%, + 100% { + opacity: 0.1; + transform: scale(0.7); + } + 50% { + opacity: 0.75; + transform: scale(1.15); + } +} +@keyframes sky-head-pulse { + 0% { + opacity: 0.9; + transform: scale(0.6); + } + 100% { + opacity: 0; + transform: scale(2.2); + } +} +@keyframes sky-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes inbox-row-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes inbox-dash-flow { + to { + stroke-dashoffset: -0.2; + } +} +@keyframes inbox-merge-draw { + from { + stroke-dashoffset: 1; + } + to { + stroke-dashoffset: 0; + } +} + +@layer components { + .sky { + --sky-star: var(--color-warning-amber); + position: relative; + overflow: hidden; + color: var(--color-primary-text); + background: + radial-gradient(70% 130% at 88% -20%, color-mix(in oklab, var(--color-command-blue) 26%, transparent), transparent 70%), + radial-gradient(45% 90% at 62% 118%, color-mix(in oklab, var(--color-warning-amber) 9%, transparent), transparent 72%), + radial-gradient(50% 110% at 8% 10%, color-mix(in oklab, var(--color-command-blue) 9%, transparent), transparent 70%); + } + /* The sky dissolves into the page instead of ending at an edge. */ + .sky::after { + content: ''; + position: absolute; + inset: auto 0 0; + height: 58px; + pointer-events: none; + background: linear-gradient(to bottom, transparent, var(--color-midnight-base)); + } + .sky-canvas { + position: absolute; + inset: 0; + display: block; + } + .sky-header { + position: relative; + z-index: 2; + padding-top: 30px; + pointer-events: none; + } + .sky-header button, + .sky-header a { + pointer-events: auto; + } + .sky-dust circle { + fill: currentColor; + } + .sky-lights { + position: absolute; + inset: 0; + pointer-events: none; + } + .sky-twinkle { + position: absolute; + width: 2px; + height: 2px; + margin: -1px; + border-radius: 999px; + background: currentColor; + will-change: opacity; + animation: sky-twinkle 5.5s ease-in-out infinite; + } + .sky-glint { + position: absolute; + width: 34px; + height: 34px; + margin: -17px; + border-radius: 999px; + background: radial-gradient(circle, color-mix(in oklab, var(--sky-star) 70%, transparent), transparent 68%); + will-change: opacity, transform; + animation: sky-glint 2.8s ease-in-out infinite; + } + .sky-head-pulse { + position: absolute; + width: 18px; + height: 18px; + margin: -9px; + border-radius: 999px; + border: 1.5px solid var(--color-primary-text); + will-change: opacity, transform; + animation: sky-head-pulse 2.8s var(--ease-out-expo) infinite; + } + .sky-lane line { + stroke: var(--color-default-border); + stroke-dasharray: 2 7; + } + .sky-lane text, + .sky-tick text, + .sky-head text { + font-size: var(--text-meta); + fill: var(--color-secondary-text); + animation: sky-fade 700ms 200ms var(--ease-out-expo) backwards; + } + .sky-tick line { + stroke: var(--color-field-border); + } + .sky-tick text { + font-variant-numeric: tabular-nums; + } + .sky-path { + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-dasharray: 1; + animation: sky-line-draw 1500ms 120ms var(--ease-out-expo) backwards; + } + .sky-line-start { + stop-color: var(--color-field-border); + } + .sky-line-middle { + stop-color: var(--color-command-blue); + } + .sky-line-end { + stop-color: var(--color-primary-text); + } + + .sky-hit { + fill: transparent; + } + .sky-point { + cursor: default; + } + .sky-mark { + transform-box: fill-box; + transform-origin: center; + animation: sky-mark-in 620ms var(--ease-out-expo) backwards; + transition: scale 200ms var(--ease-out-expo); + } + .sky-halo { + fill: var(--point-ink, var(--sky-star)); + opacity: 0; + transition: opacity 180ms var(--ease-out-expo); + } + .sky-point.is-active .sky-mark, + .sky-point:hover .sky-mark { + scale: 1.55; + } + .sky-point.is-active .sky-halo, + .sky-point:hover .sky-halo { + opacity: 0.18; + } + .sky-star path { + fill: var(--sky-star); + filter: drop-shadow(0 0 3px var(--sky-star)); + } + .sky-event { + --point-ink: var(--color-command-blue); + } + .sky-event.is-attention { + --point-ink: var(--color-attention-orange); + } + .sky-dot { + fill: var(--color-midnight-base); + stroke: var(--point-ink); + stroke-width: 2.5; + } + .sky-head-core { + fill: var(--color-primary-text); + } + .sky-tip { + position: absolute; + z-index: 3; + display: flex; + align-items: center; + gap: 9px; + width: max-content; + max-width: 280px; + padding: 7px 11px 7px 8px; + border-radius: var(--radius-md); + border: 1px solid var(--color-default-border); + background: var(--color-coal-raised); + box-shadow: var(--shadow-floating); + font-size: var(--text-caption); + color: var(--color-secondary-text); + pointer-events: none; + transform: translate(18px, -50%); + animation: tooltip-in 140ms var(--ease-out-expo) backwards; + } + .sky-tip.is-flipped { + transform: translate(calc(-100% - 18px), -50%); + } + .sky-tip strong { + font-weight: 600; + color: var(--color-primary-text); + } + .sky-tip small { + display: block; + overflow: hidden; + font-size: var(--text-meta); + color: var(--color-tertiary-text); + text-overflow: ellipsis; + white-space: nowrap; + } + + .inbox-column { + width: 100%; + max-width: 880px; + margin-inline: auto; + padding-inline: 28px; + } + /* Lifted into the sky's fade, so the two read as one surface. */ + .inbox-content { + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + gap: 22px; + margin-top: -26px; + padding-bottom: 56px; + } + .inbox-filter { + display: flex; + padding: 3px; + border-radius: var(--radius-md); + border: 1px solid var(--color-default-border); + background: var(--color-obsidian-panel); + } + + .inbox-invite { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px 24px; + padding: 22px 24px; + border-radius: var(--radius-lg); + border: 1px solid color-mix(in oklab, var(--color-command-blue) 42%, var(--color-default-border)); + background: linear-gradient(120deg, color-mix(in oklab, var(--color-command-blue) 11%, var(--color-obsidian-panel)), var(--color-obsidian-panel) 62%); + box-shadow: + var(--shadow-raised), + 0 24px 50px -30px var(--color-command-blue); + } + .inbox-invite.is-merged { + border-color: color-mix(in oklab, var(--color-success-green) 46%, var(--color-default-border)); + background: linear-gradient(120deg, color-mix(in oklab, var(--color-success-green) 12%, var(--color-obsidian-panel)), var(--color-obsidian-panel) 62%); + box-shadow: + var(--shadow-raised), + 0 24px 50px -30px var(--color-success-green); + } + .inbox-merge { + --merge-ink: var(--color-command-blue); + position: relative; + flex-shrink: 0; + width: 132px; + height: 84px; + } + .inbox-merge svg { + display: block; + } + .inbox-merge path { + stroke-width: 2.5; + stroke-linecap: round; + } + .inbox-merge-main { + stroke: var(--color-field-border); + } + .inbox-merge-branch { + stroke: var(--merge-ink); + /* pathLength is 1, so these read as fractions of the curve. */ + stroke-dasharray: 0.05 0.05; + transition: stroke-dasharray 200ms var(--ease-out-expo); + } + .inbox-merge-node { + fill: var(--color-obsidian-panel); + stroke: var(--merge-ink); + stroke-width: 2.5; + transition: fill 180ms var(--ease-out-expo); + } + .inbox-merge-avatar { + position: absolute; + left: 0; + display: grid; + border-radius: 999px; + background: var(--color-obsidian-panel); + box-shadow: 0 0 0 3px var(--color-obsidian-panel); + } + /* Pointing at Accept previews the merge before anything is committed to. */ + .inbox-invite:has(.inbox-accept:hover:not(:disabled)) .inbox-merge-branch, + .inbox-invite:has(.inbox-accept:focus-visible) .inbox-merge-branch { + stroke-dasharray: 1 0; + } + .inbox-invite:has(.inbox-accept:hover:not(:disabled)) .inbox-merge-node, + .inbox-invite:has(.inbox-accept:focus-visible) .inbox-merge-node { + fill: var(--merge-ink); + } + .inbox-merge.is-merging .inbox-merge-branch { + animation: inbox-dash-flow 600ms linear infinite; + } + .inbox-merge.is-merged { + --merge-ink: var(--color-success-green); + } + .inbox-merge.is-merged .inbox-merge-main { + stroke: var(--merge-ink); + } + .inbox-merge.is-merged .inbox-merge-branch { + stroke-dasharray: 1; + animation: inbox-merge-draw 620ms var(--ease-out-expo) backwards; + } + .inbox-merge.is-merged .inbox-merge-node { + fill: var(--merge-ink); + transform-box: fill-box; + transform-origin: center; + animation: pop 420ms 420ms var(--ease-out-expo) backwards; + } + + .inbox-panel { + overflow: hidden; + border-radius: var(--radius-lg); + border: 1px solid var(--color-default-border); + background: var(--color-obsidian-panel); + box-shadow: var(--shadow-raised); + } + .inbox-row { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + transition: background-color 120ms var(--ease-out-expo); + animation: inbox-row-in 460ms calc(min(var(--i, 0), 10) * 40ms + 260ms) var(--ease-out-expo) backwards; + } + .inbox-row + .inbox-row { + border-top: 1px solid var(--color-default-border); + } + .inbox-row:hover, + .inbox-row.is-active { + background: var(--color-hover-overlay); + } + .inbox-tile { + --tile-ink: var(--color-command-blue); + display: grid; + flex-shrink: 0; + place-items: center; + width: 38px; + height: 38px; + border-radius: 11px; + color: var(--tile-ink); + background: color-mix(in oklab, var(--tile-ink) 14%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--tile-ink) 22%, transparent); + } + .inbox-tile.is-attention { + --tile-ink: var(--color-attention-orange); + } + .inbox-person { + position: relative; + display: grid; + flex-shrink: 0; + } + .inbox-person-star { + position: absolute; + right: -3px; + bottom: -3px; + display: grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: 999px; + color: var(--color-warning-amber); + background: var(--color-coal-raised); + box-shadow: 0 0 0 2px var(--color-obsidian-panel); + } + .inbox-row-body { + display: block; + flex: 1; + min-width: 0; + border-radius: var(--radius-sm); + } + a.inbox-row-body:focus-visible { + outline: 2px solid var(--color-command-blue); + outline-offset: 4px; + } + .inbox-row-title { + display: block; + overflow: hidden; + font-size: var(--text-body); + font-weight: 500; + line-height: 1.35; + color: var(--color-primary-text); + text-overflow: ellipsis; + white-space: nowrap; + } + a.inbox-row-body:hover .inbox-row-title { + color: var(--color-command-blue); + } + .inbox-row-meta { + display: block; + overflow: hidden; + margin-top: 2px; + font-size: var(--text-caption); + color: var(--color-tertiary-text); + text-overflow: ellipsis; + white-space: nowrap; + } + .inbox-row-time { + flex-shrink: 0; + min-width: 60px; + font-size: var(--text-caption); + font-variant-numeric: tabular-nums; + text-align: right; + color: var(--color-tertiary-text); + } + /* Quiet until the row is in play, but always there for the keyboard. */ + .inbox-row-action { + display: grid; + flex-shrink: 0; + place-items: center; + width: 30px; + height: 30px; + border-radius: var(--radius-sm); + color: var(--color-secondary-text); + opacity: 0; + transition: + opacity 120ms var(--ease-out-expo), + background-color 120ms var(--ease-out-expo), + color 120ms var(--ease-out-expo); + } + .inbox-row:hover .inbox-row-action, + .inbox-row.is-active .inbox-row-action, + .inbox-row-action:focus-visible { + opacity: 1; + } + .inbox-row-action:hover { + color: var(--color-success-green); + background: var(--color-hover-overlay); + } + .inbox-row-action:focus-visible { + outline: 2px solid var(--color-command-blue); + } + .inbox-ghost { + display: block; + height: 11px; + border-radius: 4px; + background: var(--color-hover-overlay); + animation: pulse 1.6s ease-in-out infinite; + } + .inbox-ghost.is-small { + height: 8px; + margin-top: 8px; + } +} + +/* On a light canvas amber has to be chosen again: the text token reads brown as a mark, and dark dust reads as dirt. */ +[data-theme='light'] .sky { + --sky-star: #b87a00; +} +[data-theme='light'] .sky-dust { + opacity: 0.35; +} + +/* Subtlety is what a high-contrast reader can't rely on: no dust, no wash, solid lines. */ +[data-theme$='hc'] .sky { + background: none; +} +[data-theme$='hc'] .sky-dust, +[data-theme$='hc'] .sky-twinkle, +[data-theme$='hc'] .sky-glint { + display: none; +} +[data-theme$='hc'] .sky-star path { + filter: none; +} + +@media (prefers-reduced-motion: reduce) { + .sky-twinkle, + .sky-glint, + .sky-head-pulse, + .sky-lane text, + .sky-tick text, + .sky-head text, + .sky-path, + .sky-mark, + .sky-tip, + .inbox-merge.is-merging .inbox-merge-branch, + .inbox-merge.is-merged .inbox-merge-branch, + .inbox-merge.is-merged .inbox-merge-node, + .inbox-row, + .inbox-ghost { + animation: none; + } +} diff --git a/GitWeb/src/renderer/src/views/CollaboratorsView.tsx b/GitWeb/src/renderer/src/views/CollaboratorsView.tsx index 2af43df..ba73592 100644 --- a/GitWeb/src/renderer/src/views/CollaboratorsView.tsx +++ b/GitWeb/src/renderer/src/views/CollaboratorsView.tsx @@ -2,6 +2,7 @@ import { useEffect, useId, useState, type FormEvent, type ReactNode } from 'reac import { AlertCircle, AlertTriangle, Clock, Loader2, Lock, RefreshCw, UserMinus, UserPlus, Users, X } from 'lucide-react' import ConfirmDialog from '../components/ConfirmDialog' import Combobox from '../components/Combobox' +import UserSearchField from '../components/UserSearchField' import { Avatar, LoginForm } from './PullRequestsView' import { usePullRequestStore } from '../store/pullRequestStore' import { sessionKeyOf, useCollaboratorStore } from '../store/collaboratorStore' @@ -81,6 +82,13 @@ function CollaboratorsPanel({ remote, user }: { remote: RemoteInfo; user: GitHub ...(extra && !isStandardRole(extra) ? [{ value: extra, label: extra }] : []) ] + function accessStatus(login: string): string | null { + const same = (other: string | null): boolean => other?.toLowerCase() === login.toLowerCase() + if (data?.collaborators.some((c) => same(c.login))) return t.hasAccess + if (data?.invitations.some((i) => !i.expired && same(i.login))) return t.pendingChip + return null + } + async function handleAdd(event: FormEvent): Promise { event.preventDefault() if (await add(username, isOrg ? newRole : undefined)) setUsername('') @@ -150,20 +158,18 @@ function CollaboratorsPanel({ remote, user }: { remote: RemoteInfo; user: GitHub - { - setUsername(e.target.value) + onChange={(value) => { + setUsername(value) if (shownFieldError) clearFieldError() }} + statusOf={accessStatus} placeholder={t.usernamePlaceholder} - autoComplete="off" - spellCheck={false} - aria-invalid={shownFieldError ? true : undefined} - aria-describedby={`${baseId}-hint`} - className="field mt-1.5 h-[34px] w-full py-0" + invalid={Boolean(shownFieldError)} + describedBy={`${baseId}-hint`} /> {isOrg && ( diff --git a/GitWeb/src/renderer/src/views/InitialView.tsx b/GitWeb/src/renderer/src/views/InitialView.tsx index 0b1befb..8097d2b 100644 --- a/GitWeb/src/renderer/src/views/InitialView.tsx +++ b/GitWeb/src/renderer/src/views/InitialView.tsx @@ -17,6 +17,7 @@ import { DownloadCloud, FolderOpen, FolderPlus, + Inbox, Info, Loader2, Search, @@ -28,6 +29,7 @@ import { import Github from '../components/icons/Github' import { useRepoStore } from '../store/repoStore' import { useGithubAuthStore } from '../store/githubAuthStore' +import { pendingMessageCount, useInboxStore } from '../store/inboxStore' import GithubLoginModal from '../components/GithubLoginModal' import RepoPickerModal from '../components/RepoPickerModal' import CloneModal from '../components/CloneModal' @@ -219,6 +221,8 @@ function MoveToGroupMenu({ } function InitialView(): JSX.Element { + const pendingMessages = useInboxStore(pendingMessageCount) + const messagesLabel = useT().inbox.title const recentRepos = useRepoStore((s) => s.recentRepos) const recentRepoChecks = useRepoStore((s) => s.recentRepoChecks) const selectedRepoPaths = useRepoStore((s) => s.selectedRepoPaths) @@ -575,6 +579,19 @@ function InitialView(): JSX.Element { summary.ok > 0 && { tone: 'bg-success-green', text: t.summaryOk(summary.ok) } ].filter(Boolean) as { tone: string; text: string }[] + const messagesLink = ( + + ) + return (
{/* The bench: who you are, and the three ways in. Stays put while the @@ -640,7 +657,8 @@ function InitialView(): JSX.Element { )} -
+
+ {messagesLink} + + + ) +} + +function StarRow({ star, isNew, active, style, onActivate }: RowProps & { star: RepositoryStar; isNew: boolean }): JSX.Element { + const t = useT().inbox + const locale = useLocale() + return ( +
  • onActivate(true)} onMouseLeave={() => onActivate(false)}> + + + + + + + onActivate(true)} onBlur={() => onActivate(false)} className="inbox-row-body"> + + {star.login} {t.starredVerb} + + {star.repoFullName} + + {isNew && {t.newStar}} + +
  • + ) +} + +function PaneMessage({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }): JSX.Element { + return ( +
    + {icon} +

    {title}

    +

    {children}

    +
    + ) +} + +function LoadingRows({ label }: { label: string }): JSX.Element { + return ( +
    + {label} + {[58, 42, 50].map((width) => ( +
    + + + + + +
    + ))} +
    + ) +} + +function MessagesView(): JSX.Element { + const invitations = useInboxStore((s) => s.invitations) + const notifications = useInboxStore((s) => s.notifications) + const stars = useInboxStore((s) => s.stars) + const signedIn = useInboxStore((s) => s.signedIn) + const isLoading = useInboxStore((s) => s.isLoading) + const answeringId = useInboxStore((s) => s.answeringId) + const checkedAt = useInboxStore((s) => s.checkedAt) + const error = useInboxStore((s) => s.error) + const notice = useInboxStore((s) => s.notice) + const refresh = useInboxStore((s) => s.refresh) + const accept = useInboxStore((s) => s.accept) + const decline = useInboxStore((s) => s.decline) + const markRead = useInboxStore((s) => s.markRead) + const markAllRead = useInboxStore((s) => s.markAllRead) + const markStarsSeen = useInboxStore((s) => s.markStarsSeen) + const clearNotice = useInboxStore((s) => s.clearNotice) + const prUser = usePullRequestStore((s) => s.user) + const startScreenUser = useGithubAuthStore((s) => s.user) + const currentRepoPath = useRepoStore((s) => s.currentRepoPath) + const setView = useRepoStore((s) => s.setView) + const [declining, setDeclining] = useState(null) + const [joined, setJoined] = useState(null) + const [filter, setFilter] = useState('all') + const [activeKey, setActiveKey] = useState(null) + // Opening the tab counts as seeing the stars, but they stay marked for this visit. + const [newStarsSince] = useState(() => useInboxStore.getState().starsSeenAt) + const invitationsHeading = useId() + const timelineHeading = useId() + const messages = useT() + const t = messages.inbox + const locale = useLocale() + + useEffect(() => { + // What the background check last saw can be minutes old by the time someone + // looks, but one already on its way is as fresh as this one would be. + if (!useInboxStore.getState().isLoading) void refresh() + markStarsSeen() + }, [refresh, markStarsSeen]) + + const viewer = prUser ?? startScreenUser + const busy = answeringId !== null + const newStars = stars.filter((star) => isNewStar(star, newStarsSince)).length + const roleLabel = (role: string): string => + (REPOSITORY_ROLES as readonly string[]).includes(role) ? messages.collaborators.roles[role as RepositoryRole] : role + + const summaryParts = [ + invitations.length > 0 && t.summaryInvitations(invitations.length), + notifications.length > 0 && t.summaryNotifications(notifications.length), + newStars > 0 && t.summaryStars(newStars) + ].filter((part): part is string => Boolean(part)) + const summary = + summaryParts.length > 0 + ? t.summary(new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }).format(summaryParts)) + : stars.length > 0 + ? t.allClearWithStars(stars.length) + : t.allClear + + async function handleAccept(invitation: ReceivedInvitation): Promise { + await accept(invitation) + if (!useInboxStore.getState().invitations.some((i) => i.id === invitation.id)) setJoined(invitation) + } + + function dismissJoined(): void { + setJoined(null) + clearNotice() + } + + const showBoth = notifications.length > 0 && stars.length > 0 + const shownFilter = showBoth ? filter : 'all' + const groups = buildTimeline(shownFilter === 'stars' ? [] : notifications, shownFilter === 'notifications' ? [] : stars) + // Stable between renders: the sky lays itself out from this, and hovering a row must not redraw it. + const everything = useMemo(() => buildTimeline(notifications, stars).flatMap((group) => group.items), [notifications, stars]) + const filters: { id: Filter; label: string; count: number }[] = [ + { id: 'all', label: t.filterAll, count: notifications.length + stars.length }, + { id: 'notifications', label: t.filterNotifications, count: notifications.length }, + { id: 'stars', label: t.filterStars, count: stars.length } + ] + const hasMessages = invitations.length + notifications.length + stars.length > 0 || joined !== null + + let rowIndex = 0 + const renderItem = (item: TimelineItem): JSX.Element => { + const shared = { + active: activeKey === item.key, + style: { '--i': rowIndex++ } as CSSProperties, + onActivate: (active: boolean) => setActiveKey(active ? item.key : null) + } + return item.type === 'notification' ? ( + void markRead(id)} {...shared} /> + ) : ( + + ) + } + + return ( +
    + +
    +
    + {!currentRepoPath && ( + + )} +
    +

    {t.title}

    + {signedIn && ( +

    + {summary} +

    + )} +
    +
    +
    + {checkedAt !== null && {t.checkedAt(relativeTime(locale, new Date(checkedAt).toISOString()))}} + +
    +
    +
    + +
    + {error && ( +

    + + {error} +

    + )} + {notice && !joined && ( +
    + + {notice} + +
    + )} + + {signedIn === false ? ( + } title={t.signedOutTitle}> + {t.signedOut} + + ) : hasMessages ? ( + <> + {(invitations.length > 0 || joined) && ( +
    +

    + {t.invitationsTitle} +

    + {joined && ( + + )} + {invitations.map((invitation) => { + const repo = invitation.repoFullName + const accepting = answeringId === invitation.id + return ( +
    + +
    +

    + {repo} + {invitation.isPrivate && ( + + + + )} +

    +

    + {t.invitedBy(invitation.inviterLogin ?? t.someone, roleLabel(invitation.role), relativeTime(locale, invitation.createdAt))} +

    +
    +
    + + + + + +
    +
    + ) + })} +
    + )} + + {notifications.length + stars.length > 0 && ( +
    +

    + {t.timelineTitle} +

    + {(showBoth || notifications.length > 0) && ( +
    + {showBoth ? ( +
    + {filters.map(({ id, label, count }) => ( + + ))} +
    + ) : ( + + )} + {notifications.length > 0 && shownFilter !== 'stars' && ( + + )} +
    + )} +
    + {groups.map((group) => ( +
    +

    {t.buckets[group.bucket]}

    +
      {group.items.map(renderItem)}
    +
    + ))} +
    +
    + )} + + ) : signedIn === null ? ( + isLoading && + ) : ( + } title={t.emptyTitle}> + {t.empty} + + )} +
    + + { + if (declining) void decline(declining) + setDeclining(null) + }} + onCancel={() => setDeclining(null)} + /> +
    + ) +} + +export default MessagesView diff --git a/GitWeb/src/shared/github.ts b/GitWeb/src/shared/github.ts index 081607a..8945cf7 100644 --- a/GitWeb/src/shared/github.ts +++ b/GitWeb/src/shared/github.ts @@ -201,6 +201,55 @@ export function normalizeGitHubUsername(input: string): string | null { return /^[a-z\d](?:[a-z\d_-]{0,37}[a-z\d_])?$/i.test(login) ? login : null } +/** An invitation the signed-in account received to join someone's repository. */ +export interface ReceivedInvitation { + id: number + repoFullName: string + /** The invitation's page on GitHub. */ + htmlUrl: string + isPrivate: boolean + /** GitHub can leave this out, for instance when the account that invited was deleted. */ + inviterLogin: string | null + inviterAvatarUrl: string | null + role: string + createdAt: string +} + +/** An unread item from GitHub's notifications: a pull request, an issue, a mention, and so on. */ +export interface GitHubNotification { + id: string + /** GitHub's subject type, such as PullRequest or Issue. */ + kind: string + /** Why GitHub notified this account, such as review_requested or mention. */ + reason: string + title: string + repoFullName: string + htmlUrl: string + updatedAt: string +} + +/** Someone starring a repository the signed-in account owns or works on through an organization. */ +export interface RepositoryStar { + repoFullName: string + login: string + avatarUrl: string + profileUrl: string + starredAt: string +} + +/** One account offered while typing who to invite. */ +export interface GitHubUserSuggestion { + login: string + /** The profile name, when the person set one. It is what tells two similar logins apart. */ + name: string | null + avatarUrl: string +} + +/** What to look up for the text in the field: the login in a pasted `@octocat` or URL, otherwise the words as typed. */ +export function userSearchQuery(input: string): string { + return normalizeGitHubUsername(input) ?? input.trim() +} + /** * Whether this person's access can be changed from here. The owner cannot lose * access, inherited access is not this repository's to remove, and an admin diff --git a/GitWeb/src/shared/i18n/index.ts b/GitWeb/src/shared/i18n/index.ts index 069ab9b..83bab3a 100644 --- a/GitWeb/src/shared/i18n/index.ts +++ b/GitWeb/src/shared/i18n/index.ts @@ -17,6 +17,7 @@ import collaborators from './messages/collaborators' import githubLogin from './messages/githubLogin' import graph from './messages/graph' import graphTools from './messages/graphTools' +import inbox from './messages/inbox' import ui from './messages/ui' import security from './messages/security' import stores from './messages/stores' @@ -38,6 +39,7 @@ const namespaces = { graph, graphTools, history, + inbox, initial, main, publish, diff --git a/GitWeb/src/shared/i18n/messages/collaborators.ts b/GitWeb/src/shared/i18n/messages/collaborators.ts index 7d022f2..ccd38f5 100644 --- a/GitWeb/src/shared/i18n/messages/collaborators.ts +++ b/GitWeb/src/shared/i18n/messages/collaborators.ts @@ -8,7 +8,12 @@ const ptBR = { loading: 'Carregando colaboradores…', addTitle: 'Adicionar pessoa', usernameLabel: 'Username no GitHub', - usernamePlaceholder: 'username', + usernamePlaceholder: 'Digite o nome ou username', + suggestionsAria: 'Pessoas encontradas no GitHub', + searching: 'Buscando no GitHub…', + searchFailed: 'Não foi possível buscar agora. Você ainda pode digitar o username completo.', + noMatches: (query: string) => `Ninguém encontrado para "${query}".`, + hasAccess: 'Já tem acesso', roleLabel: 'Permissão', invite: 'Convidar', addHint: 'Quem ainda não tem acesso recebe um convite do GitHub e só entra depois de aceitar.', @@ -78,7 +83,12 @@ const en: M = { loading: 'Loading collaborators…', addTitle: 'Add a person', usernameLabel: 'GitHub username', - usernamePlaceholder: 'username', + usernamePlaceholder: 'Type a name or username', + suggestionsAria: 'People found on GitHub', + searching: 'Searching GitHub…', + searchFailed: 'Could not search right now. You can still type the full username.', + noMatches: (query) => `No one found for "${query}".`, + hasAccess: 'Has access', roleLabel: 'Permission', invite: 'Invite', addHint: 'Anyone without access yet gets an invitation from GitHub and joins after accepting it.', @@ -144,7 +154,12 @@ const es: M = { loading: 'Cargando colaboradores…', addTitle: 'Añadir persona', usernameLabel: 'Usuario de GitHub', - usernamePlaceholder: 'usuario', + usernamePlaceholder: 'Escribe el nombre o usuario', + suggestionsAria: 'Personas encontradas en GitHub', + searching: 'Buscando en GitHub…', + searchFailed: 'No se pudo buscar ahora. Aún puedes escribir el usuario completo.', + noMatches: (query) => `No se encontró a nadie para "${query}".`, + hasAccess: 'Ya tiene acceso', roleLabel: 'Permiso', invite: 'Invitar', addHint: 'Quien aún no tiene acceso recibe una invitación de GitHub y entra después de aceptarla.', diff --git a/GitWeb/src/shared/i18n/messages/inbox.ts b/GitWeb/src/shared/i18n/messages/inbox.ts new file mode 100644 index 0000000..956528c --- /dev/null +++ b/GitWeb/src/shared/i18n/messages/inbox.ts @@ -0,0 +1,216 @@ +const ptBR = { + title: 'Mensagens', + refresh: 'Atualizar', + refreshAria: 'Atualizar mensagens', + loading: 'Buscando mensagens…', + signedOutTitle: 'Você não está conectado ao GitHub', + signedOut: 'Entre com o GitHub para receber aqui convites, pull requests, menções e estrelas.', + emptyTitle: 'Tudo em dia', + empty: 'Convites, pull requests, menções e estrelas aparecem aqui assim que chegarem.', + invitationsTitle: 'Convites para repositórios', + someone: 'Alguém', + invitedBy: (inviter: string, role: string, when: string) => `${inviter} convidou você · ${role} · ${when}`, + privateRepo: 'Privado', + accept: 'Aceitar', + acceptAria: (repo: string) => `Aceitar convite para ${repo}`, + decline: 'Recusar', + declineAria: (repo: string) => `Recusar convite para ${repo}`, + viewOnGitHub: 'Ver no GitHub', + viewOnGitHubAria: (repo: string) => `Ver convite para ${repo} no GitHub`, + declineDialogTitle: 'Recusar convite?', + declineDialogMessage: (repo: string) => `Você não terá acesso a ${repo}. Para entrar depois, precisará de um novo convite.`, + accepted: (repo: string) => `Convite aceito. Agora você tem acesso a ${repo}.`, + acceptedShort: 'Convite aceito. Agora você tem acesso a este repositório.', + skyNow: 'agora', + declined: (repo: string) => `Convite para ${repo} recusado.`, + timelineTitle: 'Atividade', + summary: (list: string) => `Você tem ${list}.`, + summaryInvitations: (count: number) => (count === 1 ? '1 convite esperando resposta' : `${count} convites esperando resposta`), + summaryNotifications: (count: number) => (count === 1 ? '1 notificação' : `${count} notificações`), + summaryStars: (count: number) => (count === 1 ? '1 estrela nova' : `${count} estrelas novas`), + allClear: 'Nada esperando por você.', + allClearWithStars: (count: number) => `Nada esperando por você, e ${count === 1 ? '1 estrela' : `${count} estrelas`} nos últimos 30 dias.`, + checkedAt: (when: string) => `Verificado ${when}`, + openOnGitHub: 'Abrir no GitHub', + filterAria: 'Filtrar atividade', + filterAll: 'Tudo', + filterNotifications: 'Notificações', + filterStars: 'Estrelas', + buckets: { today: 'Hoje', yesterday: 'Ontem', week: 'Esta semana', earlier: 'Antes' }, + starredVerb: 'deu uma estrela', + markRead: 'Marcar como lida', + markReadAria: (title: string) => `Marcar como lida: ${title}`, + markAllRead: 'Marcar todas como lidas', + kinds: { + PullRequest: 'Pull request', + Issue: 'Issue', + Commit: 'Commit', + Release: 'Release', + Discussion: 'Discussão', + CheckSuite: 'CI' + } as Record, + reasons: { + review_requested: 'pediram sua revisão', + mention: 'mencionaram você', + team_mention: 'mencionaram seu time', + assign: 'atribuído a você', + author: 'você criou', + comment: 'você comentou', + state_change: 'mudou de estado', + ci_activity: 'resultado do CI', + security_alert: 'alerta de segurança' + } as Record, + newStar: 'Nova', + viewProfileAria: (login: string) => `Ver o perfil de ${login} no GitHub`, + notificationInviteTitle: 'Novo convite para repositório', + notificationInviteBody: (inviter: string, repo: string) => `${inviter} convidou você para ${repo}.`, + notificationStarTitle: 'Nova estrela', + notificationStarBody: (login: string, repo: string) => `${login} deu uma estrela em ${repo}.`, + notificationManyTitle: (count: number) => `${count} novas mensagens` +} + +type M = typeof ptBR + +const en: M = { + title: 'Messages', + refresh: 'Refresh', + refreshAria: 'Refresh messages', + loading: 'Looking for messages…', + signedOutTitle: 'You are not signed in to GitHub', + signedOut: 'Sign in with GitHub to get invitations, pull requests, mentions, and stars here.', + emptyTitle: 'All caught up', + empty: 'Invitations, pull requests, mentions, and stars show up here as soon as they arrive.', + invitationsTitle: 'Repository invitations', + someone: 'Someone', + invitedBy: (inviter, role, when) => `${inviter} invited you · ${role} · ${when}`, + privateRepo: 'Private', + accept: 'Accept', + acceptAria: (repo) => `Accept invitation to ${repo}`, + decline: 'Decline', + declineAria: (repo) => `Decline invitation to ${repo}`, + viewOnGitHub: 'View on GitHub', + viewOnGitHubAria: (repo) => `View invitation to ${repo} on GitHub`, + declineDialogTitle: 'Decline invitation?', + declineDialogMessage: (repo) => `You will not get access to ${repo}. Joining later will take a new invitation.`, + accepted: (repo) => `Invitation accepted. You now have access to ${repo}.`, + acceptedShort: 'Invitation accepted. You now have access to this repository.', + skyNow: 'now', + declined: (repo) => `Invitation to ${repo} declined.`, + timelineTitle: 'Activity', + summary: (list) => `You have ${list}.`, + summaryInvitations: (count) => (count === 1 ? '1 invitation waiting for an answer' : `${count} invitations waiting for an answer`), + summaryNotifications: (count) => (count === 1 ? '1 notification' : `${count} notifications`), + summaryStars: (count) => (count === 1 ? '1 new star' : `${count} new stars`), + allClear: 'Nothing is waiting for you.', + allClearWithStars: (count) => `Nothing is waiting for you, and ${count === 1 ? '1 star' : `${count} stars`} in the last 30 days.`, + checkedAt: (when) => `Checked ${when}`, + openOnGitHub: 'Open on GitHub', + filterAria: 'Filter activity', + filterAll: 'All', + filterNotifications: 'Notifications', + filterStars: 'Stars', + buckets: { today: 'Today', yesterday: 'Yesterday', week: 'This week', earlier: 'Earlier' }, + starredVerb: 'starred', + markRead: 'Mark as read', + markReadAria: (title) => `Mark as read: ${title}`, + markAllRead: 'Mark all as read', + kinds: { + PullRequest: 'Pull request', + Issue: 'Issue', + Commit: 'Commit', + Release: 'Release', + Discussion: 'Discussion', + CheckSuite: 'CI' + }, + reasons: { + review_requested: 'review requested', + mention: 'mentioned you', + team_mention: 'mentioned your team', + assign: 'assigned to you', + author: 'you created it', + comment: 'you commented', + state_change: 'state changed', + ci_activity: 'CI result', + security_alert: 'security alert' + }, + newStar: 'New', + viewProfileAria: (login) => `View ${login}'s profile on GitHub`, + notificationInviteTitle: 'New repository invitation', + notificationInviteBody: (inviter, repo) => `${inviter} invited you to ${repo}.`, + notificationStarTitle: 'New star', + notificationStarBody: (login, repo) => `${login} starred ${repo}.`, + notificationManyTitle: (count) => `${count} new messages` +} + +const es: M = { + title: 'Mensajes', + refresh: 'Actualizar', + refreshAria: 'Actualizar mensajes', + loading: 'Buscando mensajes…', + signedOutTitle: 'No has iniciado sesión en GitHub', + signedOut: 'Inicia sesión con GitHub para recibir aquí invitaciones, pull requests, menciones y estrellas.', + emptyTitle: 'Todo al día', + empty: 'Las invitaciones, pull requests, menciones y estrellas aparecen aquí en cuanto llegan.', + invitationsTitle: 'Invitaciones a repositorios', + someone: 'Alguien', + invitedBy: (inviter, role, when) => `${inviter} te invitó · ${role} · ${when}`, + privateRepo: 'Privado', + accept: 'Aceptar', + acceptAria: (repo) => `Aceptar la invitación a ${repo}`, + decline: 'Rechazar', + declineAria: (repo) => `Rechazar la invitación a ${repo}`, + viewOnGitHub: 'Ver en GitHub', + viewOnGitHubAria: (repo) => `Ver la invitación a ${repo} en GitHub`, + declineDialogTitle: '¿Rechazar la invitación?', + declineDialogMessage: (repo) => `No tendrás acceso a ${repo}. Para entrar después, necesitarás una nueva invitación.`, + accepted: (repo) => `Invitación aceptada. Ahora tienes acceso a ${repo}.`, + acceptedShort: 'Invitación aceptada. Ahora tienes acceso a este repositorio.', + skyNow: 'ahora', + declined: (repo) => `Invitación a ${repo} rechazada.`, + timelineTitle: 'Actividad', + summary: (list) => `Tienes ${list}.`, + summaryInvitations: (count) => (count === 1 ? '1 invitación esperando respuesta' : `${count} invitaciones esperando respuesta`), + summaryNotifications: (count) => (count === 1 ? '1 notificación' : `${count} notificaciones`), + summaryStars: (count) => (count === 1 ? '1 estrella nueva' : `${count} estrellas nuevas`), + allClear: 'Nada te está esperando.', + allClearWithStars: (count) => `Nada te está esperando, y ${count === 1 ? '1 estrella' : `${count} estrellas`} en los últimos 30 días.`, + checkedAt: (when) => `Comprobado ${when}`, + openOnGitHub: 'Abrir en GitHub', + filterAria: 'Filtrar actividad', + filterAll: 'Todo', + filterNotifications: 'Notificaciones', + filterStars: 'Estrellas', + buckets: { today: 'Hoy', yesterday: 'Ayer', week: 'Esta semana', earlier: 'Antes' }, + starredVerb: 'dio una estrella', + markRead: 'Marcar como leída', + markReadAria: (title) => `Marcar como leída: ${title}`, + markAllRead: 'Marcar todas como leídas', + kinds: { + PullRequest: 'Pull request', + Issue: 'Issue', + Commit: 'Commit', + Release: 'Release', + Discussion: 'Discusión', + CheckSuite: 'CI' + }, + reasons: { + review_requested: 'piden tu revisión', + mention: 'te mencionaron', + team_mention: 'mencionaron a tu equipo', + assign: 'asignado a ti', + author: 'lo creaste tú', + comment: 'comentaste', + state_change: 'cambió de estado', + ci_activity: 'resultado de CI', + security_alert: 'alerta de seguridad' + }, + newStar: 'Nueva', + viewProfileAria: (login) => `Ver el perfil de ${login} en GitHub`, + notificationInviteTitle: 'Nueva invitación a un repositorio', + notificationInviteBody: (inviter, repo) => `${inviter} te invitó a ${repo}.`, + notificationStarTitle: 'Nueva estrella', + notificationStarBody: (login, repo) => `${login} dio una estrella a ${repo}.`, + notificationManyTitle: (count) => `${count} mensajes nuevos` +} + +export default { 'pt-BR': ptBR, en, es } as const diff --git a/GitWeb/src/shared/i18n/messages/main.ts b/GitWeb/src/shared/i18n/messages/main.ts index bacdeac..2f33ad5 100644 --- a/GitWeb/src/shared/i18n/messages/main.ts +++ b/GitWeb/src/shared/i18n/messages/main.ts @@ -137,6 +137,7 @@ const ptBR = { notAuthenticated: (host: string) => `Você não está autenticado em ${host}. Entre com o GitHub e tente de novo.`, requestRejected: (status: number) => `GitHub recusou a solicitação (${status}).`, checkRunsTruncated: (shown: number, total: number) => `só os primeiros ${shown} de ${total} check runs foram lidos`, + invitationGone: 'Esse convite não está mais disponível: ele pode ter sido cancelado ou ter expirado. Atualize a lista.', collaboratorNotFound: (username: string) => `O GitHub não encontrou ${username}, ou sua conta não pode gerenciar este repositório.`, collaboratorsForbidden: (reason: string) => @@ -294,6 +295,7 @@ const en: MainMessages = { notAuthenticated: (host) => `You are not signed in to ${host}. Sign in with GitHub and try again.`, requestRejected: (status) => `GitHub rejected the request (${status}).`, checkRunsTruncated: (shown, total) => `only the first ${shown} of ${total} check runs were read`, + invitationGone: 'This invitation is no longer available: it may have been cancelled or expired. Refresh the list.', collaboratorNotFound: (username) => `GitHub could not find ${username}, or your account cannot manage this repository.`, collaboratorsForbidden: (reason) => `The organization did not allow this sign-in to read the collaborator list. Ask an organization admin to approve the app or, if you signed in with a token, check its access. (${reason})`, @@ -449,6 +451,7 @@ const es: MainMessages = { notAuthenticated: (host) => `No has iniciado sesión en ${host}. Inicia sesión con GitHub e inténtalo de nuevo.`, requestRejected: (status) => `GitHub rechazó la solicitud (${status}).`, checkRunsTruncated: (shown, total) => `solo se leyeron los primeros ${shown} de ${total} check runs`, + invitationGone: 'Esta invitación ya no está disponible: puede haberse cancelado o caducado. Actualiza la lista.', collaboratorNotFound: (username) => `GitHub no encontró a ${username}, o tu cuenta no puede gestionar este repositorio.`, collaboratorsForbidden: (reason) => `La organización no permitió a este inicio de sesión leer la lista de colaboradores. Pide a un administrador de la organización que apruebe la app o, si entraste con un token, revisa su acceso. (${reason})`, diff --git a/GitWeb/test/collaborators.test.ts b/GitWeb/test/collaborators.test.ts index 2042a0e..0baf74b 100644 --- a/GitWeb/test/collaborators.test.ts +++ b/GitWeb/test/collaborators.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { canManageCollaborator, normalizeGitHubUsername } from '../src/shared/github' +import { canManageCollaborator, normalizeGitHubUsername, userSearchQuery } from '../src/shared/github' import { getMessages } from '../src/shared/i18n' vi.mock('../src/main/credentials', () => ({ getSecret: vi.fn(async () => 'token'), saveSecret: vi.fn(), deleteSecret: vi.fn() })) vi.mock('../src/main/httpFetch', () => ({ httpFetch: vi.fn() })) const { httpFetch } = await import('../src/main/httpFetch') -const { addCollaborator, cancelRepositoryInvitation, getRepositoryCollaborators, removeCollaborator } = await import( +const { addCollaborator, cancelRepositoryInvitation, getRepositoryCollaborators, removeCollaborator, searchUsers } = await import( '../src/main/githubApi' ) @@ -35,6 +35,45 @@ beforeEach(() => { vi.mocked(httpFetch).mockReset() }) +describe('searchUsers', () => { + it('returns people with their names, and leaves out results that are not users', async () => { + serve({ + '/graphql': { + status: 200, + body: { + data: { + search: { + nodes: [{ login: 'ana', name: 'Ana Souza', avatarUrl: 'https://a/ana' }, { login: 'bia', name: '', avatarUrl: 'https://a/bia' }, {}, null] + } + } + } + } + }) + await expect(searchUsers('github.com', 'an')).resolves.toEqual([ + { login: 'ana', name: 'Ana Souza', avatarUrl: 'https://a/ana' }, + { login: 'bia', name: null, avatarUrl: 'https://a/bia' } + ]) + const init = vi.mocked(httpFetch).mock.calls[0][1] as RequestInit + expect(JSON.parse(String(init.body)).variables.q).toBe('an type:user') + }) + + it('reports a failure instead of pretending nobody was found', async () => { + serve({ '/graphql': { status: 200, body: { errors: [{ message: 'rate limited' }] } } }) + await expect(searchUsers('github.com', 'an')).rejects.toThrow('rate limited') + }) +}) + +describe('userSearchQuery', () => { + it('finds the login in a pasted mention or profile URL', () => { + expect(userSearchQuery(' @octocat ')).toBe('octocat') + expect(userSearchQuery('https://github.com/octocat')).toBe('octocat') + }) + + it('keeps a full name as typed', () => { + expect(userSearchQuery(' Ana Souza ')).toBe('Ana Souza') + }) +}) + describe('getRepositoryCollaborators', () => { it('treats everyone on a personal repository as direct, without asking twice', async () => { serve({ diff --git a/GitWeb/test/inbox.test.ts b/GitWeb/test/inbox.test.ts new file mode 100644 index 0000000..3244bff --- /dev/null +++ b/GitWeb/test/inbox.test.ts @@ -0,0 +1,428 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getMessages } from '../src/shared/i18n' +import type { GitHubNotification, ReceivedInvitation, RepositoryStar } from '../src/shared/github' + +vi.mock('../src/main/credentials', () => ({ getSecret: vi.fn(async () => 'token'), saveSecret: vi.fn(), deleteSecret: vi.fn() })) +vi.mock('../src/main/httpFetch', () => ({ httpFetch: vi.fn() })) + +const github = { + getReceivedInvitations: vi.fn(), + acceptInvitation: vi.fn(), + declineInvitation: vi.fn(), + getNotifications: vi.fn(), + markNotificationRead: vi.fn(), + markAllNotificationsRead: vi.fn(), + getRecentStars: vi.fn() +} +const app = { showNotification: vi.fn(async () => undefined) } +vi.stubGlobal('window', { api: { github, app } }) +const storage = new Map() +vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value) +}) +// The store picks its language from the OS, and the assertions below are in pt-BR. +vi.stubGlobal('navigator', { languages: ['pt-BR'] }) + +const { getSecret } = await import('../src/main/credentials') +const { httpFetch } = await import('../src/main/httpFetch') +const { tMain } = await import('../src/main/i18n') +const { acceptInvitation, declineInvitation, getNotifications, getReceivedInvitations, getRecentStars, markAllNotificationsRead, markNotificationRead } = + await import('../src/main/githubApi') +const { pendingMessageCount, useInboxStore } = await import('../src/renderer/src/store/inboxStore') +const { buildTimeline } = await import('../src/renderer/src/lib/messageTimeline') +const { layoutSky } = await import('../src/renderer/src/lib/activitySky') + +const t = getMessages('pt-BR').inbox +const store = (): ReturnType => useInboxStore.getState() + +// The store remembers what it announced for the whole run, so each test gets its own ids. +let nextId = 1 +function invitation(repoFullName = 'ana/financeiro'): ReceivedInvitation { + return { + id: nextId++, + repoFullName, + htmlUrl: `https://github.com/${repoFullName}/invitations`, + isPrivate: true, + inviterLogin: 'ana', + inviterAvatarUrl: null, + role: 'write', + createdAt: '2026-11-06T19:32:23Z' + } +} + +function notification(id: string, change: Partial = {}): GitHubNotification { + return { + id, + kind: 'PullRequest', + reason: 'review_requested', + title: 'Corrige o frete', + repoFullName: 'ana/financeiro', + htmlUrl: 'https://github.com/ana/financeiro/pull/1', + updatedAt: '2026-11-06T19:00:00Z', + ...change + } +} + +function star(login: string, starredAt: string, repoFullName = 'me/app'): RepositoryStar { + return { repoFullName, login, avatarUrl: '', profileUrl: `https://github.com/${login}`, starredAt } +} + +const daysAgo = (days: number): string => new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString() + +function respond(status: number, body?: unknown): void { + vi.mocked(httpFetch).mockResolvedValue(new Response(body === undefined ? null : JSON.stringify(body), { status })) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getSecret).mockResolvedValue('token') + github.getNotifications.mockResolvedValue([]) + github.getRecentStars.mockResolvedValue([]) + useInboxStore.setState({ + invitations: [], + notifications: [], + stars: [], + starsSeenAt: Date.now() - 1000, + signedIn: null, + isLoading: false, + answeringId: null, + error: null, + notice: null + }) +}) + +describe('getNotifications', () => { + const api = (id: string, type: string, url: string | null, reason = 'mention'): object => ({ + id, + reason, + updated_at: '2026-11-06T19:00:00Z', + subject: { title: `t${id}`, type, url }, + repository: { full_name: 'o/r', html_url: 'https://github.com/o/r' } + }) + + it('turns API addresses into pages a person can open, and leaves invitations to their own list', async () => { + respond(200, [ + api('1', 'PullRequest', 'https://api.github.com/repos/o/r/pulls/5'), + api('2', 'Issue', 'https://api.github.com/repos/o/r/issues/6'), + api('3', 'Commit', 'https://api.github.com/repos/o/r/commits/abc'), + api('4', 'Release', 'https://api.github.com/repos/o/r/releases/9'), + api('5', 'Discussion', null), + api('6', 'CheckSuite', null), + api('7', 'RepositoryInvitation', null) + ]) + const result = await getNotifications('github.com') + expect(result?.map((n) => [n.id, n.htmlUrl])).toEqual([ + ['1', 'https://github.com/o/r/pull/5'], + ['2', 'https://github.com/o/r/issues/6'], + ['3', 'https://github.com/o/r/commit/abc'], + ['4', 'https://github.com/o/r/releases'], + ['5', 'https://github.com/o/r/discussions'], + ['6', 'https://github.com/o/r/actions'] + ]) + }) + + it('marks one thread, or everything up to a date, as read', async () => { + // GitHub answers these with 205, which has no body to parse. + respond(205) + await markNotificationRead('github.com', '42') + respond(205) + await markAllNotificationsRead('github.com', '2026-11-06T19:00:00Z') + const [first, second] = vi.mocked(httpFetch).mock.calls + expect([new URL(String(first[0])).pathname, first[1]?.method]).toEqual(['/notifications/threads/42', 'PATCH']) + expect([new URL(String(second[0])).pathname, second[1]?.method]).toEqual(['/notifications', 'PUT']) + expect(JSON.parse(String(second[1]?.body))).toEqual({ last_read_at: '2026-11-06T19:00:00Z' }) + }) +}) + +describe('getRecentStars', () => { + const answer = (edges: object[], login = 'me'): object => ({ + data: { viewer: { login, repositories: { nodes: [{ nameWithOwner: 'me/app', stargazers: { edges } }] } } } + }) + const edge = (who: string, starredAt: string): object => ({ + starredAt, + node: { login: who, avatarUrl: `https://a/${who}`, url: `https://github.com/${who}` } + }) + + it('lists stars newest first, without the account itself or old ones', async () => { + respond(200, answer([edge('old', daysAgo(60)), edge('me', daysAgo(0)), edge('ana', daysAgo(5)), edge('bia', daysAgo(1))])) + const result = await getRecentStars('github.com') + expect(result?.map((s) => s.login)).toEqual(['bia', 'ana']) + expect(result?.[0]).toMatchObject({ repoFullName: 'me/app', profileUrl: 'https://github.com/bia' }) + }) + + it('treats no sign-in as null', async () => { + vi.mocked(getSecret).mockResolvedValue(null) + await expect(getRecentStars('github.com')).resolves.toBeNull() + }) +}) + +describe('getReceivedInvitations', () => { + const api = (id: number, change: object = {}): object => ({ + id, + html_url: `https://github.com/o/r${id}/invitations`, + permissions: 'write', + created_at: '2026-11-06T19:32:23Z', + repository: { full_name: `o/r${id}`, private: true }, + inviter: { login: 'ana', avatar_url: 'https://a/ana' }, + ...change + }) + + it('maps pending invitations and leaves out expired ones', async () => { + respond(200, [api(1), api(2, { expired: true }), api(3, { inviter: null })]) + await expect(getReceivedInvitations('github.com')).resolves.toEqual([ + { + id: 1, + repoFullName: 'o/r1', + htmlUrl: 'https://github.com/o/r1/invitations', + isPrivate: true, + inviterLogin: 'ana', + inviterAvatarUrl: 'https://a/ana', + role: 'write', + createdAt: '2026-11-06T19:32:23Z' + }, + { + id: 3, + repoFullName: 'o/r3', + htmlUrl: 'https://github.com/o/r3/invitations', + isPrivate: true, + inviterLogin: null, + inviterAvatarUrl: null, + role: 'write', + createdAt: '2026-11-06T19:32:23Z' + } + ]) + }) + + it('treats no sign-in as null without calling GitHub', async () => { + vi.mocked(getSecret).mockResolvedValue(null) + await expect(getReceivedInvitations('github.com')).resolves.toBeNull() + expect(httpFetch).not.toHaveBeenCalled() + }) + + it('treats a dead token as null, but reports any other failure', async () => { + respond(401, { message: 'Bad credentials' }) + await expect(getReceivedInvitations('github.com')).resolves.toBeNull() + respond(500, { message: 'Server Error' }) + await expect(getReceivedInvitations('github.com')).rejects.toThrow('Server Error') + }) +}) + +describe('answering an invitation', () => { + it('uses PATCH to accept and DELETE to decline', async () => { + respond(204) + await acceptInvitation('github.com', 7) + respond(204) + await declineInvitation('github.com', 7) + const calls = vi.mocked(httpFetch).mock.calls.map(([url, init]) => [new URL(String(url)).pathname, init?.method]) + expect(calls).toEqual([ + ['/user/repository_invitations/7', 'PATCH'], + ['/user/repository_invitations/7', 'DELETE'] + ]) + }) + + it('explains an invitation that no longer exists', async () => { + respond(404, { message: 'Not Found' }) + await expect(acceptInvitation('github.com', 7)).rejects.toThrow(tMain().github.invitationGone) + }) +}) + +describe('inbox store', () => { + it('announces an invitation once per run, however many checks see it', async () => { + const one = invitation() + github.getReceivedInvitations.mockResolvedValue([one]) + await store().refresh() + await store().refresh() + expect(app.showNotification).toHaveBeenCalledTimes(1) + expect(app.showNotification).toHaveBeenCalledWith(t.notificationInviteTitle, t.notificationInviteBody('ana', 'ana/financeiro')) + expect(store().invitations).toEqual([one]) + expect(store().signedIn).toBe(true) + }) + + it('announces several new invitations in a single notification', async () => { + github.getReceivedInvitations.mockResolvedValue([invitation('a/one'), invitation('b/two')]) + await store().refresh() + expect(app.showNotification).toHaveBeenCalledTimes(1) + expect(app.showNotification).toHaveBeenCalledWith(t.notificationManyTitle(2), 'a/one, b/two') + expect(pendingMessageCount(store())).toBe(2) + }) + + it('stays quiet and empties every list when signed out', async () => { + useInboxStore.setState({ invitations: [invitation()], notifications: [notification('gone')], stars: [star('ana', daysAgo(0))], signedIn: true }) + github.getReceivedInvitations.mockResolvedValue(null) + github.getNotifications.mockResolvedValue(null) + github.getRecentStars.mockResolvedValue(null) + await store().refresh() + expect(store().invitations).toEqual([]) + expect(store().notifications).toEqual([]) + expect(store().stars).toEqual([]) + expect(store().signedIn).toBe(false) + expect(app.showNotification).not.toHaveBeenCalled() + }) + + it('keeps the list when a check fails', async () => { + const one = invitation() + useInboxStore.setState({ invitations: [one], signedIn: true }) + github.getReceivedInvitations.mockRejectedValue(new Error('offline')) + await store().refresh() + expect(store().invitations).toEqual([one]) + expect(store().error).toBe('offline') + expect(store().isLoading).toBe(false) + }) + + it('still delivers the rest when one source fails', async () => { + const one = invitation() + github.getReceivedInvitations.mockResolvedValue([one]) + github.getRecentStars.mockRejectedValue(new Error('stars unavailable')) + await store().refresh() + expect(store().invitations).toEqual([one]) + expect(store().error).toBe('stars unavailable') + expect(app.showNotification).toHaveBeenCalledTimes(1) + }) + + it('announces new activity on a notification it already announced', async () => { + github.getReceivedInvitations.mockResolvedValue([]) + github.getNotifications.mockResolvedValue([notification('thread-1')]) + await store().refresh() + await store().refresh() + expect(app.showNotification).toHaveBeenCalledTimes(1) + github.getNotifications.mockResolvedValue([notification('thread-1', { updatedAt: '2026-11-06T20:00:00Z' })]) + await store().refresh() + expect(app.showNotification).toHaveBeenCalledTimes(2) + }) + + it('counts and announces only stars newer than the last visit', async () => { + useInboxStore.setState({ starsSeenAt: Date.parse(daysAgo(2)) }) + github.getReceivedInvitations.mockResolvedValue([]) + github.getRecentStars.mockResolvedValue([star('fresh', daysAgo(1)), star('stale', daysAgo(3))]) + await store().refresh() + expect(pendingMessageCount(store())).toBe(1) + expect(app.showNotification).toHaveBeenCalledTimes(1) + expect(app.showNotification).toHaveBeenCalledWith(t.notificationStarTitle, t.notificationStarBody('fresh', 'me/app')) + }) + + it('remembers across runs that the stars were seen', () => { + useInboxStore.setState({ stars: [star('ana', daysAgo(1))], starsSeenAt: Date.parse(daysAgo(2)) }) + expect(pendingMessageCount(store())).toBe(1) + store().markStarsSeen() + expect(pendingMessageCount(store())).toBe(0) + expect(Number(storage.get('inbox.starsSeenAt'))).toBe(store().starsSeenAt) + }) + + it('removes a notification marked as read, and restores it if GitHub refuses', async () => { + const one = notification('r1') + useInboxStore.setState({ notifications: [one] }) + github.markNotificationRead.mockResolvedValueOnce(undefined) + await store().markRead('r1') + expect(store().notifications).toEqual([]) + + useInboxStore.setState({ notifications: [one] }) + github.markNotificationRead.mockRejectedValueOnce(new Error('denied')) + await store().markRead('r1') + expect(store().notifications).toEqual([one]) + expect(store().error).toBe('denied') + }) + + it('marks everything read only up to the newest item on screen', async () => { + useInboxStore.setState({ notifications: [notification('a', { updatedAt: '2026-11-06T20:00:00Z' }), notification('b')] }) + github.markAllNotificationsRead.mockResolvedValue(undefined) + await store().markAllRead() + expect(github.markAllNotificationsRead).toHaveBeenCalledWith('github.com', '2026-11-06T20:00:00Z') + expect(store().notifications).toEqual([]) + }) + + it('removes an answered invitation and confirms it', async () => { + const one = invitation() + useInboxStore.setState({ invitations: [one], signedIn: true }) + github.acceptInvitation.mockResolvedValue(undefined) + await store().accept(one) + expect(github.acceptInvitation).toHaveBeenCalledWith('github.com', one.id) + expect(store().invitations).toEqual([]) + expect(store().notice).toBe(t.accepted('ana/financeiro')) + }) + + it('keeps the invitation when answering fails', async () => { + const one = invitation() + useInboxStore.setState({ invitations: [one], signedIn: true }) + github.declineInvitation.mockRejectedValue(new Error('denied')) + await store().decline(one) + expect(store().invitations).toEqual([one]) + expect(store().error).toBe('denied') + expect(store().answeringId).toBeNull() + }) + + it('does not let a check that started earlier bring back an answered invitation', async () => { + const one = invitation() + useInboxStore.setState({ invitations: [one], signedIn: true }) + let finishCheck: (value: ReceivedInvitation[]) => void = () => undefined + github.getReceivedInvitations.mockReturnValue(new Promise((resolve) => (finishCheck = resolve))) + const check = store().refresh() + github.acceptInvitation.mockResolvedValue(undefined) + await store().accept(one) + finishCheck([one]) + await check + expect(store().invitations).toEqual([]) + }) +}) + +describe('buildTimeline', () => { + const now = new Date(2026, 10, 6, 15, 0) + const at = (day: number, hour = 9): string => new Date(2026, 10, day, hour).toISOString() + + it('merges notifications and stars newest first, under the day they happened', () => { + const groups = buildTimeline( + [notification('today', { updatedAt: at(6, 8) }), notification('old', { updatedAt: new Date(2026, 9, 20).toISOString() })], + [star('ana', at(6, 10)), star('bia', at(5)), star('caio', at(3))], + now + ) + expect(groups.map((group) => [group.bucket, group.items.map((item) => item.key)])).toEqual([ + ['today', ['star:me/app:ana', 'notification:today']], + ['yesterday', ['star:me/app:bia']], + ['week', ['star:me/app:caio']], + ['earlier', ['notification:old']] + ]) + }) + + it('leaves out days with nothing in them', () => { + expect(buildTimeline([], [], now)).toEqual([]) + }) +}) + +describe('layoutSky', () => { + const now = Date.parse('2026-11-06T12:00:00Z') + const ago = (days: number): string => new Date(now - days * 24 * 60 * 60 * 1000).toISOString() + const items = (stars: RepositoryStar[]) => buildTimeline([], stars, new Date(now)).flatMap((group) => group.items) + + it('runs time left to right toward the present, with one lane per repository', () => { + const sky = layoutSky(items([star('new', ago(1), 'a/one'), star('old', ago(20), 'b/two')]), 1000, 340, now) + expect(sky.lanes.map((lane) => lane.repo)).toEqual(['a/one', 'b/two']) + const [newest, oldest] = sky.points + expect(newest.item.key).toBe('star:a/one:new') + expect(newest.x).toBeGreaterThan(oldest.x) + expect(newest.y).not.toBe(oldest.y) + expect(sky.head.x).toBeGreaterThan(newest.x) + }) + + it('keeps the busiest repositories on the map and the page within bounds', () => { + const many = Array.from({ length: 9 }, (_, index) => star(`p${index}`, ago(index), `o/r${index}`)) + const sky = layoutSky(items(many), 1000, 340, now) + expect(sky.lanes).toHaveLength(5) + expect(sky.points).toHaveLength(5) + for (const point of sky.points) { + expect(point.x).toBeGreaterThan(0) + expect(point.x).toBeLessThan(1000) + expect(point.y).toBeLessThan(340) + } + }) + + it('fans out things that happened together', () => { + const sky = layoutSky(items([star('a', ago(1)), star('b', ago(1)), star('c', ago(1))]), 1000, 340, now) + expect(new Set(sky.points.map((point) => point.y)).size).toBe(3) + }) + + it('draws nothing when there is nothing to place', () => { + const sky = layoutSky([], 1000, 340, now) + expect(sky.points).toEqual([]) + expect(sky.path).toBe('') + }) +}) From 36ce2cc94d60fa1c1b5c7e5421f1c466a459f438 Mon Sep 17 00:00:00 2001 From: Paulo Carinhena <63272749+paulocarinhena@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:34:54 -0300 Subject: [PATCH 2/2] =?UTF-8?q?Registra=20a=20exce=C3=A7=C3=A3o=20do=20c?= =?UTF-8?q?=C3=A9u=20de=20atividade=20no=20DESIGN.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tela de Mensagens usa degradês e brilhos que a regra geral proíbe. A exceção agora está documentada e limitada a essa tela, como a dos gráficos do Dashboard. No alto contraste, o cartão de convite também perde a cor de fundo e a sombra colorida, para cumprir o que o documento promete. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 9 ++++++--- GitWeb/src/renderer/src/styles/global.css | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 43bf6ae..43b2e5b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -283,6 +283,8 @@ The palette is restrained at rest and categorical in motion: neutrals carry the **The Data-Viz Exception.** Charts on the Dashboard are the one place hue encodes *identity* (a language, a contributor) instead of action or state. They use their own `--viz-1…8` categorical set and a `--viz-seq-1…5` blue ramp (defined in `global.css`), validated for colour-vision deficiency against the real panel surfaces, separately stepped for dark and light. Chart text stays in text tokens; every coloured mark has a visible text label beside it. +**The Activity Sky Exception.** The Messages screen is the one operational surface allowed atmosphere, by explicit product decision: the account's last 30 days are drawn as a night sky, and that sky is a real spatial canvas plotting real data. Inside `.sky` it may use soft radial washes of Command Blue and Warning Amber, a bottom fade into Midnight Base, decorative dust, a drop-shadow glow on star marks, and a glint on new stars. Amber here encodes identity (a GitHub star), not caution; `--sky-star` replaces Warning Amber in the light theme, whose text-grade value reads brown as a mark. The invitation card just below (`.inbox-invite`) may carry a soft tint and a colored offset shadow, because it is the screen's one decision. High-contrast themes drop the washes, dust, glints, glow, card tint and colored shadow; `prefers-reduced-motion` stops all of the movement. This exception does not extend to any other screen. + **The Red Means Danger Rule.** Danger Red is reserved for destructive actions, failures, and intervention. It is forbidden as decorative emphasis. **The Contrast Before Atmosphere Rule.** Readability wins over subtlety. Muted text and placeholders must meet WCAG 2.2 AA against their actual surface, in every theme variant, not only the dark default. @@ -339,7 +341,8 @@ OpenGitDesktop uses structural layering. Midnight Base, Obsidian Panel, and Coal - **View enter**: switching tabs brings the incoming view up 6px with a 3px de-blur over 280ms (expo ease-out). It uses `backwards` fill only, so no transform or filter lingers to trap `position: fixed` menus. - **List selection**: rows mark hover and selection with a short rounded accent pill on the leading edge that grows from the row's middle (`--row-accent` recolors it; conflicts use Warning Amber). - **Tab indicator**: the active tab's underline grows from its center. -- **Assistant standby**: the empty-chat emblem breathes a soft Command Blue ring. It is the only idle loop in the app. +- **Assistant standby**: the empty-chat emblem breathes a soft Command Blue ring. Outside the Activity Sky, it is the only idle loop in the app. +- **Activity Sky**: on Messages, the graph line draws itself once on entry, and a few dust points, new stars and the "now" marker keep moving quietly. Anything that loops lives on its own layer outside the SVG, so it never repaints the sky. - All of it collapses under `prefers-reduced-motion`. ### Named Rules @@ -411,8 +414,8 @@ Switches use a `42px` by `24px` track with an `18px` thumb and explicit text lab - **Do** require explicit confirmation for destructive Git actions (discard, abort merge, drop stash, pop stash, merge or close a pull request). Danger confirms focus Cancel, not the destructive button. ### Don't: -- **Don't** turn OpenGitDesktop into glossy generic SaaS marketing with decorative gradients, hero-metric templates, or glass cards. -- **Don't** use neon glows or speculative hype aesthetics. +- **Don't** turn OpenGitDesktop into glossy generic SaaS marketing with decorative gradients, hero-metric templates, or glass cards. The Activity Sky Exception is the only allowance, and only on Messages. +- **Don't** use neon glows or speculative hype aesthetics. The restrained star glow inside the Activity Sky is the only exception. - **Don't** reduce the product to an undifferentiated CRUD admin panel; keep the daily Git path (changes → diff → commit) primary. - **Don't** compete with that path using equal-weight header cards or icon-only Settings/About buttons; Settings is a labeled tab, About lives inside it. - **Don't** use decorative grid backgrounds unless the surface is an actual charting or spatial canvas. diff --git a/GitWeb/src/renderer/src/styles/global.css b/GitWeb/src/renderer/src/styles/global.css index d3afd28..fcde006 100644 --- a/GitWeb/src/renderer/src/styles/global.css +++ b/GitWeb/src/renderer/src/styles/global.css @@ -2916,6 +2916,10 @@ [data-theme$='hc'] .sky-star path { filter: none; } +[data-theme$='hc'] .inbox-invite { + background: var(--color-obsidian-panel); + box-shadow: none; +} @media (prefers-reduced-motion: reduce) { .sky-twinkle,