Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions alembic/versions/a7c3e9d1f5b8_feedback_library_and_language.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""feedback_library_and_language

The πŸ‘/πŸ‘Ž buttons moved from the floating feedback widget onto the plot
itself, so a reaction is now about one implementation rather than a page.
Store that implementation explicitly β€” `library_id` + `language` next to the
existing `spec_id` β€” instead of parsing it back out of `path`, and index the
(spec_id, library_id) pair so votes can be counted per image later.

Both columns are nullable: page-level feedback from the floating widget
(messages, bug/idea reactions) still leaves them empty.

Revision ID: a7c3e9d1f5b8
Revises: f4b8d2c6a9e1
Create Date: 2026-09-10

"""

from typing import Sequence

import sqlalchemy as sa

from alembic import op


revision: str = "a7c3e9d1f5b8"
down_revision: str | None = "f4b8d2c6a9e1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.add_column(sa.Column("library_id", sa.String(50), nullable=True))
batch_op.add_column(sa.Column("language", sa.String(50), nullable=True))
batch_op.create_index("ix_feedback_spec_library", ["spec_id", "library_id"])


def downgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.drop_index("ix_feedback_spec_library")
batch_op.drop_column("language")
batch_op.drop_column("library_id")
43 changes: 35 additions & 8 deletions api/routers/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@
MAX_MESSAGE_LENGTH = 500
MAX_CONTACT_LENGTH = 255
RATE_LIMIT_WINDOW = timedelta(minutes=1)
# Free-text entries: 5 per minute per IP, counted over message-bearing rows only.
RATE_LIMIT_MAX = 5
# Reaction-only entries (a πŸ‘/πŸ‘Ž tap on a plot): someone flipping through the
# library carousel rates several images in quick succession, so the cap is
# looser and counted over every row from that IP.
REACTION_RATE_LIMIT_MAX = 30
# The two reactions the plot buttons send β€” the ones subject to one-vote-per-image.
PLOT_VOTE_REACTIONS = ("thumbs_up", "thumbs_down")

# Anti-spam heuristics β€” see _is_link_stuffed and the duplicate check below.
# Both branches return 200 silently (mirroring the honeypot) so bots can't
Expand Down Expand Up @@ -89,18 +96,36 @@ async def submit_feedback(

if ip_hash:
since = now_utc_naive - RATE_LIMIT_WINDOW
recent = await repo.count_recent_by_ip(ip_hash, since)
if recent >= RATE_LIMIT_MAX:
if message is not None:
recent = await repo.count_recent_by_ip(ip_hash, since, messages_only=True)
limit = RATE_LIMIT_MAX
else:
recent = await repo.count_recent_by_ip(ip_hash, since)
limit = REACTION_RATE_LIMIT_MAX
if recent >= limit:
raise HTTPException(status_code=429, detail="Too many feedback submissions, please slow down")

# Silent duplicate suppression: same message text from the same IP or
# session id within DUPLICATE_LOOKBACK is dropped without an error β€” keeps
# repeated bot copy-paste out of the DB without revealing the filter.
session_id = (payload.session_id or None) and payload.session_id[:64]
if message and await repo.has_recent_duplicate(
message,
ip_hash or None,
(payload.session_id or None) and payload.session_id[:64],
now_utc_naive - DUPLICATE_LOOKBACK,
message, ip_hash or None, session_id, now_utc_naive - DUPLICATE_LOOKBACK
):
return FeedbackResponse(status="ok")

# One πŸ‘/πŸ‘Ž per image per session: the plot buttons lock after the first
# tap, so a second vote from the same session for the same implementation
# only ever comes from someone working around the UI β€” drop it silently.
spec_id = (payload.spec_id or None) and payload.spec_id[:100]
library_id = (payload.library_id or None) and payload.library_id[:50]
language = (payload.language or None) and payload.language[:50]
if (
reaction in PLOT_VOTE_REACTIONS
and session_id
and spec_id
and library_id
and await repo.has_plot_vote(session_id, spec_id, library_id, language)
):
return FeedbackResponse(status="ok")

Expand All @@ -112,9 +137,11 @@ async def submit_feedback(
"reaction": reaction,
"contact": contact,
"path": (payload.path or None) and payload.path[:500],
"spec_id": (payload.spec_id or None) and payload.spec_id[:100],
"spec_id": spec_id,
"library_id": library_id,
"language": language,
"viewport": (payload.viewport or None) and payload.viewport[:20],
"session_id": (payload.session_id or None) and payload.session_id[:64],
"session_id": session_id,
"user_agent": user_agent,
"ip_hash": ip_hash or None,
}
Expand Down
4 changes: 4 additions & 0 deletions api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ class FeedbackRequest(BaseModel):
contact: str | None = None
path: str | None = None
spec_id: str | None = None
# The implementation a πŸ‘/πŸ‘Ž on a plot refers to; page-level feedback from
# the floating widget sends neither.
library_id: str | None = None
language: str | None = None
viewport: str | None = None
session_id: str | None = None
# Honeypot field β€” real users never fill this in. Bots auto-fill all
Expand Down
30 changes: 4 additions & 26 deletions app/src/components/FeedbackWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ import Tooltip from '@mui/material/Tooltip';
import { useAnalytics } from 'src/hooks';
import { useLocalStorage } from 'src/hooks/useLocalStorage';
import { apiPost, endpoints } from 'src/lib/api';
import { RESERVED_TOP_LEVEL } from 'src/routes/paths';
import { specIdFromPath } from 'src/routes/paths';
import { FEEDBACK_SESSION_KEY, newFeedbackSessionId } from 'src/utils/feedback';

const MAX_MESSAGE_LENGTH = 500;
const SESSION_KEY = 'anyplot_feedback_session';
const THANKS_TIMEOUT_MS = 1200;

// Floating quick-action buttons sit on the page background so they read as
Expand All @@ -46,28 +46,6 @@ const REACTIONS = [
type Reaction = (typeof REACTIONS)[number]['value'];
type Mode = 'closed' | 'quick' | 'full';

function specIdFromPath(pathname: string): string | undefined {
const segments = pathname.split('/').filter(Boolean);
if (segments.length === 0) return undefined;
if (RESERVED_TOP_LEVEL.has(segments[0])) return undefined;
return segments[0];
}

function newSessionId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return `s-${Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')}`;
}
// Browser without Web Crypto support (e.g. very old, or insecure context). The
// session id is an opaque correlation handle, not a credential β€” a coarse
// timestamp-derived id is acceptable here, but we never use Math.random().
return `s-${Date.now().toString(36)}`;
}

/**
* Floating quick-feedback widget (issue #5662). The FAB opens a small
* vertical stack of πŸ‘ / πŸ‘Ž / πŸ’¬: the thumbs submit a reaction-only entry
Expand Down Expand Up @@ -141,11 +119,11 @@ export function FeedbackWidget() {
? `translateY(-${lift - FAB_CENTER_FROM_BOTTOM_XS}px)`
: 'none';

const [sessionId, setSessionId] = useLocalStorage<string>(SESSION_KEY, '');
const [sessionId, setSessionId] = useLocalStorage<string>(FEEDBACK_SESSION_KEY, '');

const ensureSessionId = (): string => {
if (sessionId) return sessionId;
const fresh = newSessionId();
const fresh = newFeedbackSessionId();
setSessionId(fresh);
return fresh;
};
Expand Down
66 changes: 66 additions & 0 deletions app/src/hooks/useQuickReaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useCallback } from 'react';

import { useAnalytics } from 'src/hooks/useAnalytics';
import { useLocalStorage } from 'src/hooks/useLocalStorage';
import { apiPost, endpoints } from 'src/lib/api';
import {
FEEDBACK_SESSION_KEY,
newFeedbackSessionId,
type QuickReaction,
type VoteTarget,
} from 'src/utils/feedback';

/**
* Submit a reaction-only feedback entry (πŸ‘ / πŸ‘Ž) for one implementation.
*
* Headless counterpart to the FeedbackWidget's quick-stack: same endpoint,
* same session correlation and the same `feedback_submitted` analytics event
* (with `mode: "plot_overlay"`), but it leaves all UI to the caller so the
* buttons can sit directly on the plot. Unlike the widget it names the
* implementation (`library_id`, `language`) so votes can be counted per image.
*
* @returns An async `submit(reaction, target)` resolving to `true` when the
* server accepted the entry, `false` on any non-OK response or network error.
*/
export function useQuickReaction() {
const { trackEvent } = useAnalytics();
const [sessionId, setSessionId] = useLocalStorage<string>(FEEDBACK_SESSION_KEY, '');

return useCallback(
async (reaction: QuickReaction, target: VoteTarget): Promise<boolean> => {
const session = sessionId || newFeedbackSessionId();
if (!sessionId) setSessionId(session);

const path = window.location.pathname + window.location.search;

try {
await apiPost<unknown>(endpoints.feedback, {
message: null,
reaction,
contact: null,
path,
spec_id: target.specId,
library_id: target.libraryId,
language: target.language,
viewport: `${window.innerWidth}x${window.innerHeight}`,
session_id: session,
website: '',
});
} catch {
// Non-2xx or network failure β€” report as unsuccessful so the caller
// can roll back its optimistic UI. No retry: a quick vote is low-stakes.
return false;
}
trackEvent('feedback_submitted', {
path: path || undefined,
reaction,
has_contact: 'false',
spec_id: target.specId,
library: target.libraryId,
mode: 'plot_overlay',
});
return true;
},
[sessionId, setSessionId, trackEvent]
);
}
8 changes: 1 addition & 7 deletions app/src/pages/SpecPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ export function SpecPage() {
/>

<SpecDetailView
specId={specData.id}
specTitle={specData.title}
selectedLibrary={selectedLibrary || ''}
currentImpl={currentImpl}
Expand All @@ -603,17 +604,10 @@ export function SpecPage() {
codeCopied={codeCopied}
downloadDone={downloadDone}
viewMode={viewMode}
reportUrl={buildReportUrl()}
onViewModeChange={handleViewModeChange}
onImageLoad={() => setImageLoaded(true)}
onCopyCode={handleCopyCode}
onDownload={handleDownload}
onReport={() =>
trackEvent('report_issue', {
spec: specId,
library: selectedLibrary || undefined,
})
}
onTrackEvent={trackEvent}
/>

Expand Down
25 changes: 24 additions & 1 deletion app/src/routes/paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';

import { langFromPath, paths, RESERVED_TOP_LEVEL, specPath } from 'src/routes/paths';
import {
langFromPath,
paths,
RESERVED_TOP_LEVEL,
specIdFromPath,
specPath,
} from 'src/routes/paths';

describe('specPath', () => {
it('builds the cross-language hub path', () => {
Expand Down Expand Up @@ -36,6 +42,23 @@ describe('langFromPath', () => {
});
});

describe('specIdFromPath', () => {
it('returns the first segment of a spec route', () => {
expect(specIdFromPath('/scatter-basic')).toBe('scatter-basic');
expect(specIdFromPath('/scatter-basic/python/altair')).toBe('scatter-basic');
});

it('returns undefined for the root path', () => {
expect(specIdFromPath('/')).toBeUndefined();
expect(specIdFromPath('')).toBeUndefined();
});

it('returns undefined when the first segment is a reserved route', () => {
expect(specIdFromPath('/stats')).toBeUndefined();
expect(specIdFromPath('/about/team')).toBeUndefined();
});
});

describe('paths registry', () => {
it('exposes every static route', () => {
expect(paths.home).toBe('/');
Expand Down
11 changes: 11 additions & 0 deletions app/src/routes/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ export function langFromPath(pathname: string): string | undefined {
return segments[1];
}

/**
* Parse the spec id from a pathname, returns undefined for the root path or
* when the first segment is a reserved top-level route.
*/
export function specIdFromPath(pathname: string): string | undefined {
const segments = pathname.split('/').filter(Boolean);
if (segments.length === 0) return undefined;
if (RESERVED_TOP_LEVEL.has(segments[0])) return undefined;
return segments[0];
}

/**
* Central route registry β€” the single source of truth for app URLs.
* Components navigate via `paths.*` instead of hardcoded strings; spec-detail
Expand Down
Loading
Loading