Skip to content
Open
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
47 changes: 40 additions & 7 deletions apps/mobile/src/app/inbox/[...id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useUserQuery } from "@/features/auth";
import { MarkdownText } from "@/features/chat/components/MarkdownText";
import { getReportRepository } from "@/features/inbox/api";
import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet";
Expand All @@ -31,7 +32,10 @@ import {
DismissReportSheet,
} from "@/features/inbox/components/DismissReportSheet";
import { SignalCard } from "@/features/inbox/components/SignalCard";
import { SuggestedReviewers } from "@/features/inbox/components/SuggestedReviewers";
import {
type ReviewerActionExtra,
SuggestedReviewers,
} from "@/features/inbox/components/SuggestedReviewers";
import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants";
import { useInboxEngagementTracker } from "@/features/inbox/hooks/useInboxEngagementTracker";
import {
Expand All @@ -45,10 +49,14 @@ import type {
SignalFindingContent,
SignalReportPriority,
SignalReportStatus,
SuggestedReviewer,
SuggestedReviewersArtefact,
} from "@/features/inbox/types";
import { inboxStatusLabel } from "@/features/inbox/utils";
import { computeReportAgeHours, useAnalytics } from "@/lib/analytics";
import {
computeReportAgeHours,
type InboxReportActionType,
useAnalytics,
} from "@/lib/analytics";
import { useThemeColors } from "@/lib/theme";

const statusColorMap: Record<string, { bg: string; text: string }> = {
Expand Down Expand Up @@ -134,6 +142,7 @@ export default function ReportDetailScreen() {
const insets = useSafeAreaInsets();
const posthog = usePostHog();
const { data: report, isLoading, error } = useInboxReport(reportId ?? null);
const { data: me } = useUserQuery();
const [reportRepo, setReportRepo] = useState<string | null>(null);
const [dismissOpen, setDismissOpen] = useState(false);
const [discussOpen, setDiscussOpen] = useState(false);
Expand Down Expand Up @@ -177,6 +186,23 @@ export default function ReportDetailScreen() {
[tracker],
);

const fireReviewerAction = useCallback(
(action_type: InboxReportActionType, extra?: ReviewerActionExtra) => {
if (!report) return;
tracker.signalAction({
report_id: report.id,
report_title: report.title ?? null,
report_age_hours: computeReportAgeHours(report.created_at),
action_type,
surface: "detail_pane",
is_bulk: false,
bulk_size: 1,
...extra,
});
},
[report, tracker],
);

const handleToggleSignals = useCallback(() => {
// Fire analytics outside the state updater — Strict Mode double-invokes
// updaters in development, which would double-fire the event.
Expand Down Expand Up @@ -221,13 +247,13 @@ export default function ReportDetailScreen() {
return null;
}, [artefacts]);

const suggestedReviewers = useMemo((): SuggestedReviewer[] => {
const reviewerArtefact = useMemo((): SuggestedReviewersArtefact | null => {
for (const a of artefacts) {
if (a.type === "suggested_reviewers") {
return (a.content as SuggestedReviewer[]) ?? [];
return a as SuggestedReviewersArtefact;
}
}
return [];
return null;
}, [artefacts]);

const findingsBySignalId = useMemo(() => {
Expand Down Expand Up @@ -471,7 +497,14 @@ export default function ReportDetailScreen() {
)}

{/* Suggested reviewers */}
<SuggestedReviewers reviewers={suggestedReviewers} />
{reviewerArtefact && (
<SuggestedReviewers
reportId={report.id}
artefact={reviewerArtefact}
meUuid={me?.uuid}
fireAction={fireReviewerAction}
/>
)}

{/* Signals */}
{signals.length > 0 && (
Expand Down
28 changes: 28 additions & 0 deletions apps/mobile/src/features/inbox/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
SignalReportsQueryParams,
SignalReportsResponse,
SignalReportTask,
SuggestedReviewerWriteEntry,
} from "./types";

export async function getSignalReports(
Expand Down Expand Up @@ -195,6 +196,33 @@ export async function getSignalReportArtefacts(
return { results, count: data.count ?? results.length };
}

/** Replace the content of a report artefact (full PUT, not a partial update). */
export async function updateSignalReportArtefact(
reportId: string,
artefactId: string,
content: SuggestedReviewerWriteEntry[],
): Promise<void> {
const baseUrl = getBaseUrl();
const projectId = getProjectId();

const response = await authedFetch(
`${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/artefacts/${artefactId}/`,
{
method: "PUT",
body: JSON.stringify({ content }),
},
);

if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new HttpError(
response.status,
response.statusText,
errorText || "Failed to update suggested reviewers",
);
}
}

export async function getSignalReportSignals(
reportId: string,
): Promise<SignalReportSignalsResponse> {
Expand Down
126 changes: 126 additions & 0 deletions apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Text } from "@components/text";
import { MagnifyingGlass } from "phosphor-react-native";
import { useMemo, useState } from "react";
import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
TextInput,
View,
} from "react-native";
import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports";
import type { AvailableSuggestedReviewer, SuggestedReviewer } from "../types";
import { buildReviewerOptions, reviewerMatchesAvailable } from "../utils";
import { ReviewerOptionRow } from "./ReviewerOptionRow";

interface EditReviewersSheetProps {
visible: boolean;
reviewers: SuggestedReviewer[];
meUuid?: string | null;
onClose: () => void;
onToggle: (option: AvailableSuggestedReviewer) => void;
}

export function EditReviewersSheet({
visible,
reviewers,
meUuid,
onClose,
onToggle,
}: EditReviewersSheetProps) {
const { bottom, sheetContentTop } = useScreenInsets();
const themeColors = useThemeColors();
const { data: available, isLoading } = useAvailableSuggestedReviewers({
enabled: visible,
});
const [query, setQuery] = useState("");

const options = useMemo(
() => buildReviewerOptions(available?.results ?? [], meUuid ?? undefined),
[available?.results, meUuid],
);

const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter(
(o) =>
o.name.toLowerCase().includes(q) ||
o.email.toLowerCase().includes(q) ||
o.github_login.toLowerCase().includes(q),
);
}, [options, query]);

return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<View
className="flex-1 bg-background"
style={{ paddingTop: sheetContentTop() }}
>
<View className="flex-row items-center justify-between border-gray-6 border-b px-4 pb-3">
<Text className="font-semibold text-[18px] text-gray-12">
Add reviewer
</Text>
<Pressable onPress={onClose}>
<Text className="font-semibold text-[14px] text-accent-9">
Done
</Text>
</Pressable>
</View>

<View className="px-4 pt-3">
<View className="flex-row items-center gap-2 rounded-lg bg-gray-2 px-3 py-2">
<MagnifyingGlass size={16} color={themeColors.gray[9]} />
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Filter users…"
placeholderTextColor={themeColors.gray[9]}
autoCapitalize="none"
autoCorrect={false}
className="min-w-0 flex-1 text-[14px] text-gray-12"
/>
</View>
</View>

{isLoading && options.length === 0 ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={themeColors.accent[9]} />
</View>
) : filtered.length === 0 ? (
<View className="flex-1 items-center justify-center p-6">
<Text className="text-[14px] text-gray-10">No users found</Text>
</View>
) : (
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: bottom("roomy"),
}}
>
{filtered.map((reviewer) => (
<ReviewerOptionRow
key={reviewer.uuid}
reviewer={reviewer}
selected={reviewers.some((r) =>
reviewerMatchesAvailable(r, reviewer),
)}
onPress={() => onToggle(reviewer)}
/>
))}
</ScrollView>
)}
</View>
</Modal>
);
}
96 changes: 6 additions & 90 deletions apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { Text } from "@components/text";
import { Check } from "phosphor-react-native";
import { useMemo } from "react";
import {
ActivityIndicator,
Image,
Modal,
Pressable,
ScrollView,
Expand All @@ -14,55 +12,14 @@ import { useScreenInsets } from "@/hooks/useScreenInsets";
import { useThemeColors } from "@/lib/theme";
import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports";
import { useInboxFilterStore } from "../stores/inboxFilterStore";
import type { AvailableSuggestedReviewer } from "../types";
import { buildReviewerOptions } from "../utils";
import { ReviewerOptionRow } from "./ReviewerOptionRow";

interface ReviewerFilterSheetProps {
visible: boolean;
onClose: () => void;
}

interface ReviewerOption {
uuid: string;
name: string;
email: string;
github_login: string;
isMe: boolean;
}

function buildReviewerOptions(
reviewers: AvailableSuggestedReviewer[],
currentUserUuid: string | undefined,
): ReviewerOption[] {
const seen = new Set<string>();
const options: ReviewerOption[] = [];

for (const r of reviewers) {
if (!r.uuid || seen.has(r.uuid)) continue;
seen.add(r.uuid);
options.push({
uuid: r.uuid,
name: r.name?.trim() || "",
email: r.email?.trim() || "",
github_login: r.github_login?.trim() || "",
isMe: r.uuid === currentUserUuid,
});
}

// Sort: "Me" first, then alphabetical by name
options.sort((a, b) => {
if (a.isMe && !b.isMe) return -1;
if (!a.isMe && b.isMe) return 1;
return (a.name || a.email).localeCompare(b.name || b.email);
});

return options;
}

function displayName(r: ReviewerOption): string {
const base = r.name || r.email || "Unknown user";
return r.isMe ? `${base} (Me)` : base;
}

export function ReviewerFilterSheet({
visible,
onClose,
Expand Down Expand Up @@ -136,55 +93,14 @@ export function ReviewerFilterSheet({
}}
>
{options.map((reviewer, index) => {
const isSelected = suggestedReviewerFilter.includes(
reviewer.uuid,
);
const showDivider = reviewer.isMe && index < options.length - 1;

return (
<View key={reviewer.uuid}>
<Pressable
<ReviewerOptionRow
reviewer={reviewer}
selected={suggestedReviewerFilter.includes(reviewer.uuid)}
onPress={() => toggleSuggestedReviewer(reviewer.uuid)}
className="flex-row items-center justify-between rounded-md px-2 py-2.5 active:bg-gray-3"
>
<View className="min-w-0 flex-1 flex-row items-center gap-2.5">
{reviewer.github_login ? (
<Image
source={{
uri: `https://github.com/${reviewer.github_login}.png?size=32`,
}}
className="h-6 w-6 rounded-full bg-gray-4"
/>
) : (
<View className="h-6 w-6 items-center justify-center rounded-full bg-gray-4">
<Text className="text-[11px] text-gray-10">
{(reviewer.name ||
reviewer.email ||
"?")[0].toUpperCase()}
</Text>
</View>
)}
<View className="min-w-0 flex-1">
<Text
className="text-[14px] text-gray-12"
numberOfLines={1}
>
{displayName(reviewer)}
</Text>
{reviewer.email && (
<Text
className="text-[12px] text-gray-9"
numberOfLines={1}
>
{reviewer.email}
</Text>
)}
</View>
</View>
{isSelected && (
<Check size={16} color={themeColors.gray[12]} />
)}
</Pressable>
/>
{showDivider && (
<View className="mx-2 my-1 border-gray-6 border-t" />
)}
Expand Down
Loading
Loading