mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
Refactor screens and components for improved readability and consistency
- Simplified state management and data fetching in IncidentEpisodeDetailScreen. - Enhanced code clarity by using arrow functions consistently and removing unnecessary destructuring. - Improved type annotations across various screens for better TypeScript support. - Streamlined the rendering of components in IncidentEpisodesScreen and IncidentsScreen. - Updated NotificationPreferencesScreen to use consistent function signatures and improved readability. - Refactored ProjectSelectionScreen and SettingsScreen for better structure and clarity. - Enhanced LoginScreen and ServerUrlScreen with clearer type definitions and improved error handling. - Updated storage utilities to ensure consistent type usage and improved code clarity. - Refactored theme context and spacing utilities for better type safety and readability. - Improved color and date utility functions for better maintainability.
This commit is contained in:
@@ -547,4 +547,3 @@ export const InboundEmailDomain: string | undefined =
|
||||
|
||||
export const InboundEmailWebhookSecret: string | undefined =
|
||||
process.env["INBOUND_EMAIL_WEBHOOK_SECRET"] || undefined;
|
||||
|
||||
|
||||
@@ -73,9 +73,9 @@ export default class UserMiddleware {
|
||||
}
|
||||
|
||||
// 2. Fallback: Check Authorization: Bearer <token> header (mobile app flow)
|
||||
const authHeader: string | undefined = req.headers[
|
||||
"authorization"
|
||||
] as string | undefined;
|
||||
const authHeader: string | undefined = req.headers["authorization"] as
|
||||
| string
|
||||
| undefined;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
@@ -1118,7 +1118,8 @@ export class Service extends DatabaseService<Model> {
|
||||
},
|
||||
],
|
||||
message: pushMessage,
|
||||
deviceType: notificationRuleItem.userPush.deviceType! as PushDeviceType,
|
||||
deviceType: notificationRuleItem.userPush
|
||||
.deviceType! as PushDeviceType,
|
||||
},
|
||||
{
|
||||
projectId: options.projectId,
|
||||
@@ -1194,7 +1195,8 @@ export class Service extends DatabaseService<Model> {
|
||||
},
|
||||
],
|
||||
message: pushMessage,
|
||||
deviceType: notificationRuleItem.userPush.deviceType! as PushDeviceType,
|
||||
deviceType: notificationRuleItem.userPush
|
||||
.deviceType! as PushDeviceType,
|
||||
},
|
||||
{
|
||||
projectId: options.projectId,
|
||||
@@ -1271,7 +1273,8 @@ export class Service extends DatabaseService<Model> {
|
||||
},
|
||||
],
|
||||
message: pushMessage,
|
||||
deviceType: notificationRuleItem.userPush.deviceType! as PushDeviceType,
|
||||
deviceType: notificationRuleItem.userPush
|
||||
.deviceType! as PushDeviceType,
|
||||
},
|
||||
{
|
||||
projectId: options.projectId,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
import { registerRootComponent } from "expo";
|
||||
|
||||
import App from './App';
|
||||
import App from "./App";
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
/*
|
||||
* registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
* It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
* the environment is set up appropriately
|
||||
*/
|
||||
registerRootComponent(App);
|
||||
|
||||
@@ -28,7 +28,12 @@ function AppContent(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: theme.colors.backgroundPrimary },
|
||||
]}
|
||||
>
|
||||
<StatusBar style={theme.isDark ? "light" : "dark"} />
|
||||
<RootNavigator />
|
||||
<OfflineBanner />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AxiosResponse } from "axios";
|
||||
import apiClient from "./client";
|
||||
import type {
|
||||
ListResponse,
|
||||
@@ -17,7 +18,7 @@ export async function fetchAlerts(
|
||||
query.currentAlertState = { isResolvedState: false };
|
||||
}
|
||||
|
||||
const response = await apiClient.post(
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/api/alert/get-list?skip=${skip}&limit=${limit}`,
|
||||
{
|
||||
query,
|
||||
@@ -45,7 +46,7 @@ export async function fetchAlertById(
|
||||
projectId: string,
|
||||
alertId: string,
|
||||
): Promise<AlertItem> {
|
||||
const response = await apiClient.post(
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
"/api/alert/get-list?skip=0&limit=1",
|
||||
{
|
||||
query: { _id: alertId },
|
||||
@@ -72,7 +73,7 @@ export async function fetchAlertById(
|
||||
export async function fetchAlertStates(
|
||||
projectId: string,
|
||||
): Promise<AlertState[]> {
|
||||
const response = await apiClient.post(
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
"/api/alert-state/get-list?skip=0&limit=20",
|
||||
{
|
||||
query: {},
|
||||
@@ -98,7 +99,7 @@ export async function fetchAlertStateTimeline(
|
||||
projectId: string,
|
||||
alertId: string,
|
||||
): Promise<StateTimelineItem[]> {
|
||||
const response = await apiClient.post(
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
"/api/alert-state-timeline/get-list?skip=0&limit=50",
|
||||
{
|
||||
query: { alertId },
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import axios from "axios";
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import apiClient from "./client";
|
||||
import { getServerUrl } from "../storage/serverUrl";
|
||||
import { storeTokens, clearTokens } from "../storage/keychain";
|
||||
import {
|
||||
storeTokens,
|
||||
clearTokens,
|
||||
type StoredTokens,
|
||||
} from "../storage/keychain";
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
@@ -18,7 +22,7 @@ export interface LoginResponse {
|
||||
|
||||
export async function validateServerUrl(url: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.get(`${url}/api/status`, {
|
||||
const response: AxiosResponse = await axios.get(`${url}/api/status`, {
|
||||
timeout: 10000,
|
||||
});
|
||||
return response.status === 200;
|
||||
@@ -31,9 +35,9 @@ export async function login(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<LoginResponse> {
|
||||
const serverUrl = await getServerUrl();
|
||||
const serverUrl: string = await getServerUrl();
|
||||
|
||||
const response = await apiClient.post(
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`${serverUrl}/identity/login`,
|
||||
{
|
||||
data: {
|
||||
@@ -53,7 +57,8 @@ export async function login(
|
||||
},
|
||||
);
|
||||
|
||||
const responseData = response.data;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const responseData: any = response.data;
|
||||
|
||||
// Check if 2FA is required
|
||||
if (
|
||||
@@ -91,9 +96,9 @@ export async function login(
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
const serverUrl = await getServerUrl();
|
||||
const serverUrl: string = await getServerUrl();
|
||||
const { getTokens } = await import("../storage/keychain");
|
||||
const tokens = await getTokens();
|
||||
const tokens: StoredTokens | null = await getTokens();
|
||||
|
||||
if (tokens?.refreshToken) {
|
||||
await apiClient.post(
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosError } from "axios";
|
||||
import axios, {
|
||||
AxiosInstance,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
AxiosError,
|
||||
} from "axios";
|
||||
import { getServerUrl } from "../storage/serverUrl";
|
||||
import {
|
||||
getCachedAccessToken,
|
||||
getTokens,
|
||||
storeTokens,
|
||||
clearTokens,
|
||||
type StoredTokens,
|
||||
} from "../storage/keychain";
|
||||
|
||||
let isRefreshing = false;
|
||||
let isRefreshing: boolean = false;
|
||||
let refreshSubscribers: Array<(token: string) => void> = [];
|
||||
let onAuthFailure: (() => void) | null = null;
|
||||
|
||||
@@ -16,7 +22,7 @@ function subscribeTokenRefresh(callback: (token: string) => void): void {
|
||||
}
|
||||
|
||||
function onTokenRefreshed(newToken: string): void {
|
||||
refreshSubscribers.forEach((callback) => {
|
||||
refreshSubscribers.forEach((callback: (token: string) => void) => {
|
||||
callback(newToken);
|
||||
});
|
||||
refreshSubscribers = [];
|
||||
@@ -40,7 +46,7 @@ apiClient.interceptors.request.use(
|
||||
config.baseURL = await getServerUrl();
|
||||
}
|
||||
|
||||
const token = getCachedAccessToken();
|
||||
const token: string | null = getCachedAccessToken();
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
@@ -51,11 +57,13 @@ apiClient.interceptors.request.use(
|
||||
|
||||
// Response interceptor: handle 401 with token refresh queue
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
(response: AxiosResponse) => {
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
const originalRequest: InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
} = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
@@ -64,7 +72,7 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve: (value: AxiosResponse) => void) => {
|
||||
subscribeTokenRefresh((newToken: string) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
@@ -78,15 +86,18 @@ apiClient.interceptors.response.use(
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const tokens = await getTokens();
|
||||
const tokens: StoredTokens | null = await getTokens();
|
||||
if (!tokens?.refreshToken) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
const serverUrl = await getServerUrl();
|
||||
const response = await axios.post(`${serverUrl}/identity/refresh-token`, {
|
||||
refreshToken: tokens.refreshToken,
|
||||
});
|
||||
const serverUrl: string = await getServerUrl();
|
||||
const response: AxiosResponse = await axios.post(
|
||||
`${serverUrl}/identity/refresh-token`,
|
||||
{
|
||||
refreshToken: tokens.refreshToken,
|
||||
},
|
||||
);
|
||||
|
||||
const { accessToken, refreshToken, refreshTokenExpiresAt } =
|
||||
response.data;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AxiosResponse } from "axios";
|
||||
import apiClient from "./client";
|
||||
import type {
|
||||
ListResponse,
|
||||
@@ -17,7 +18,7 @@ export async function fetchIncidents(
|
||||
query.currentIncidentState = { isResolvedState: false };
|
||||
}
|
||||
|
||||
const response = await apiClient.post(
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/api/incident/get-list?skip=${skip}&limit=${limit}`,
|
||||
{
|
||||
query,
|
||||
@@ -46,7 +47,7 @@ export async function fetchIncidentById(
|
||||
projectId: string,
|
||||
incidentId: string,
|
||||
): Promise<IncidentItem> {
|
||||
const response = await apiClient.post(
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
"/api/incident/get-list?skip=0&limit=1",
|
||||
{
|
||||
query: { _id: incidentId },
|
||||
@@ -74,7 +75,7 @@ export async function fetchIncidentById(
|
||||
export async function fetchIncidentStates(
|
||||
projectId: string,
|
||||
): Promise<IncidentState[]> {
|
||||
const response = await apiClient.post(
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
"/api/incident-state/get-list?skip=0&limit=20",
|
||||
{
|
||||
query: {},
|
||||
@@ -100,7 +101,7 @@ export async function fetchIncidentStateTimeline(
|
||||
projectId: string,
|
||||
incidentId: string,
|
||||
): Promise<StateTimelineItem[]> {
|
||||
const response = await apiClient.post(
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
"/api/incident-state-timeline/get-list?skip=0&limit=50",
|
||||
{
|
||||
query: { incidentId },
|
||||
|
||||
@@ -7,7 +7,11 @@ export async function registerPushDevice(params: {
|
||||
projectId: string;
|
||||
}): Promise<void> {
|
||||
const deviceType =
|
||||
Platform.OS === "ios" ? "iOS" : Platform.OS === "android" ? "Android" : "Web";
|
||||
Platform.OS === "ios"
|
||||
? "iOS"
|
||||
: Platform.OS === "android"
|
||||
? "Android"
|
||||
: "Web";
|
||||
|
||||
try {
|
||||
await apiClient.post("/api/user-push/register", {
|
||||
@@ -25,9 +29,7 @@ export async function registerPushDevice(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterPushDevice(
|
||||
deviceToken: string,
|
||||
): Promise<void> {
|
||||
export async function unregisterPushDevice(deviceToken: string): Promise<void> {
|
||||
try {
|
||||
await apiClient.post("/api/user-push/unregister", {
|
||||
deviceToken: deviceToken,
|
||||
|
||||
@@ -16,15 +16,15 @@ export default function AlertCard({
|
||||
}: AlertCardProps): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const stateColor = alert.currentAlertState?.color
|
||||
const stateColor: string = alert.currentAlertState?.color
|
||||
? rgbToHex(alert.currentAlertState.color)
|
||||
: theme.colors.textTertiary;
|
||||
|
||||
const severityColor = alert.alertSeverity?.color
|
||||
const severityColor: string = alert.alertSeverity?.color
|
||||
? rgbToHex(alert.alertSeverity.color)
|
||||
: theme.colors.textTertiary;
|
||||
|
||||
const timeString = formatRelativeTime(alert.createdAt);
|
||||
const timeString: string = formatRelativeTime(alert.createdAt);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -41,12 +41,7 @@ export default function AlertCard({
|
||||
accessibilityLabel={`Alert ${alert.alertNumberWithPrefix || alert.alertNumber}, ${alert.title}. State: ${alert.currentAlertState?.name ?? "unknown"}. Severity: ${alert.alertSeverity?.name ?? "unknown"}.`}
|
||||
>
|
||||
<View style={styles.topRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.number,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.number, { color: theme.colors.textTertiary }]}>
|
||||
{alert.alertNumberWithPrefix || `#${alert.alertNumber}`}
|
||||
</Text>
|
||||
<Text style={[styles.time, { color: theme.colors.textTertiary }]}>
|
||||
@@ -73,7 +68,9 @@ export default function AlertCard({
|
||||
]}
|
||||
>
|
||||
<View style={[styles.dot, { backgroundColor: stateColor }]} />
|
||||
<Text style={[styles.badgeText, { color: theme.colors.textPrimary }]}>
|
||||
<Text
|
||||
style={[styles.badgeText, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{alert.currentAlertState.name}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -81,10 +78,7 @@ export default function AlertCard({
|
||||
|
||||
{alert.alertSeverity ? (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
{ backgroundColor: severityColor + "26" },
|
||||
]}
|
||||
style={[styles.badge, { backgroundColor: severityColor + "26" }]}
|
||||
>
|
||||
<Text style={[styles.badgeText, { color: severityColor }]}>
|
||||
{alert.alertSeverity.name}
|
||||
@@ -105,7 +99,7 @@ export default function AlertCard({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
card: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
|
||||
@@ -17,17 +17,14 @@ function EmptyIcon({
|
||||
icon: EmptyIcon;
|
||||
color: string;
|
||||
}): React.JSX.Element {
|
||||
// Simple geometric SVG-style icons using View primitives
|
||||
// Monochrome, clean, professional — not cartoon/playful
|
||||
/*
|
||||
* Simple geometric SVG-style icons using View primitives
|
||||
* Monochrome, clean, professional — not cartoon/playful
|
||||
*/
|
||||
if (icon === "incidents") {
|
||||
return (
|
||||
<View style={styles.iconContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.iconShield,
|
||||
{ borderColor: color },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.iconShield, { borderColor: color }]}>
|
||||
<View style={[styles.iconCheckmark, { backgroundColor: color }]} />
|
||||
</View>
|
||||
</View>
|
||||
@@ -78,7 +75,11 @@ export default function EmptyState({
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.titleSmall,
|
||||
{ color: theme.colors.textPrimary, textAlign: "center", marginTop: 20 },
|
||||
{
|
||||
color: theme.colors.textPrimary,
|
||||
textAlign: "center",
|
||||
marginTop: 20,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
@@ -102,7 +103,7 @@ export default function EmptyState({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -63,9 +63,7 @@ export default function EpisodeCard(
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.topRow}>
|
||||
<Text
|
||||
style={[styles.number, { color: theme.colors.textTertiary }]}
|
||||
>
|
||||
<Text style={[styles.number, { color: theme.colors.textTertiary }]}>
|
||||
{episode.episodeNumberWithPrefix || `#${episode.episodeNumber}`}
|
||||
</Text>
|
||||
<Text style={[styles.time, { color: theme.colors.textTertiary }]}>
|
||||
@@ -102,10 +100,7 @@ export default function EpisodeCard(
|
||||
|
||||
{severity ? (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
{ backgroundColor: severityColor + "26" },
|
||||
]}
|
||||
style={[styles.badge, { backgroundColor: severityColor + "26" }]}
|
||||
>
|
||||
<Text style={[styles.badgeText, { color: severityColor }]}>
|
||||
{severity.name}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
import { rgbToHex } from "../utils/color";
|
||||
import { formatRelativeTime } from "../utils/date";
|
||||
import type { IncidentItem } from "../api/types";
|
||||
import type { IncidentItem, NamedEntity } from "../api/types";
|
||||
|
||||
interface IncidentCardProps {
|
||||
incident: IncidentItem;
|
||||
@@ -16,16 +16,16 @@ export default function IncidentCard({
|
||||
}: IncidentCardProps): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const stateColor = incident.currentIncidentState?.color
|
||||
const stateColor: string = incident.currentIncidentState?.color
|
||||
? rgbToHex(incident.currentIncidentState.color)
|
||||
: theme.colors.textTertiary;
|
||||
|
||||
const severityColor = incident.incidentSeverity?.color
|
||||
const severityColor: string = incident.incidentSeverity?.color
|
||||
? rgbToHex(incident.incidentSeverity.color)
|
||||
: theme.colors.textTertiary;
|
||||
|
||||
const monitorCount = incident.monitors?.length ?? 0;
|
||||
const timeString = formatRelativeTime(
|
||||
const monitorCount: number = incident.monitors?.length ?? 0;
|
||||
const timeString: string = formatRelativeTime(
|
||||
incident.declaredAt || incident.createdAt,
|
||||
);
|
||||
|
||||
@@ -44,12 +44,7 @@ export default function IncidentCard({
|
||||
accessibilityLabel={`Incident ${incident.incidentNumberWithPrefix || incident.incidentNumber}, ${incident.title}. State: ${incident.currentIncidentState?.name ?? "unknown"}. Severity: ${incident.incidentSeverity?.name ?? "unknown"}.`}
|
||||
>
|
||||
<View style={styles.topRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.number,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.number, { color: theme.colors.textTertiary }]}>
|
||||
{incident.incidentNumberWithPrefix || `#${incident.incidentNumber}`}
|
||||
</Text>
|
||||
<Text style={[styles.time, { color: theme.colors.textTertiary }]}>
|
||||
@@ -76,7 +71,9 @@ export default function IncidentCard({
|
||||
]}
|
||||
>
|
||||
<View style={[styles.dot, { backgroundColor: stateColor }]} />
|
||||
<Text style={[styles.badgeText, { color: theme.colors.textPrimary }]}>
|
||||
<Text
|
||||
style={[styles.badgeText, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{incident.currentIncidentState.name}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -84,10 +81,7 @@ export default function IncidentCard({
|
||||
|
||||
{incident.incidentSeverity ? (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
{ backgroundColor: severityColor + "26" },
|
||||
]}
|
||||
style={[styles.badge, { backgroundColor: severityColor + "26" }]}
|
||||
>
|
||||
<Text style={[styles.badgeText, { color: severityColor }]}>
|
||||
{incident.incidentSeverity.name}
|
||||
@@ -101,14 +95,18 @@ export default function IncidentCard({
|
||||
style={[styles.monitors, { color: theme.colors.textSecondary }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{incident.monitors.map((m) => m.name).join(", ")}
|
||||
{incident.monitors
|
||||
.map((m: NamedEntity) => {
|
||||
return m.name;
|
||||
})
|
||||
.join(", ")}
|
||||
</Text>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
card: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function ProjectBadge({
|
||||
}: ProjectBadgeProps): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const dotColor = color || theme.colors.actionPrimary;
|
||||
const dotColor: string = color || theme.colors.actionPrimary;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -31,7 +31,7 @@ export default function ProjectBadge({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -2,12 +2,7 @@ import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
export type SeverityLevel =
|
||||
| "critical"
|
||||
| "major"
|
||||
| "minor"
|
||||
| "warning"
|
||||
| "info";
|
||||
export type SeverityLevel = "critical" | "major" | "minor" | "warning" | "info";
|
||||
|
||||
interface SeverityBadgeProps {
|
||||
severity: SeverityLevel;
|
||||
@@ -43,8 +38,8 @@ export default function SeverityBadge({
|
||||
},
|
||||
};
|
||||
|
||||
const colors = colorMap[severity];
|
||||
const displayLabel = label || severity;
|
||||
const colors: { text: string; bg: string } = colorMap[severity];
|
||||
const displayLabel: string = label || severity;
|
||||
|
||||
return (
|
||||
<View style={[styles.badge, { backgroundColor: colors.bg }]}>
|
||||
@@ -55,7 +50,7 @@ export default function SeverityBadge({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
badge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
|
||||
@@ -18,11 +18,11 @@ export default function SkeletonCard({
|
||||
variant = "card",
|
||||
}: SkeletonCardProps): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const opacity = useRef(new Animated.Value(0.3)).current;
|
||||
const reduceMotion = useRef(false);
|
||||
const opacity: Animated.Value = useRef(new Animated.Value(0.3)).current;
|
||||
const reduceMotion: React.MutableRefObject<boolean> = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
|
||||
AccessibilityInfo.isReduceMotionEnabled().then((enabled: boolean) => {
|
||||
reduceMotion.current = enabled;
|
||||
});
|
||||
}, []);
|
||||
@@ -33,7 +33,7 @@ export default function SkeletonCard({
|
||||
return;
|
||||
}
|
||||
|
||||
const animation = Animated.loop(
|
||||
const animation: Animated.CompositeAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0.7,
|
||||
@@ -98,10 +98,7 @@ export default function SkeletonCard({
|
||||
if (variant === "detail") {
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.detailContainer,
|
||||
{ opacity },
|
||||
]}
|
||||
style={[styles.detailContainer, { opacity }]}
|
||||
accessibilityLabel="Loading content"
|
||||
accessibilityRole="progressbar"
|
||||
>
|
||||
@@ -137,22 +134,24 @@ export default function SkeletonCard({
|
||||
},
|
||||
]}
|
||||
>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<View key={index} style={styles.detailRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.detailLabel,
|
||||
{ backgroundColor: theme.colors.backgroundTertiary },
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.detailValue,
|
||||
{ backgroundColor: theme.colors.backgroundTertiary },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{Array.from({ length: 3 }).map((_: unknown, index: number) => {
|
||||
return (
|
||||
<View key={index} style={styles.detailRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.detailLabel,
|
||||
{ backgroundColor: theme.colors.backgroundTertiary },
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.detailValue,
|
||||
{ backgroundColor: theme.colors.backgroundTertiary },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
@@ -209,23 +208,25 @@ export default function SkeletonCard({
|
||||
/>
|
||||
</View>
|
||||
{/* Body lines */}
|
||||
{Array.from({ length: Math.max(lines - 1, 1) }).map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.line,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundTertiary,
|
||||
width: lineWidths[index % lineWidths.length],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: Math.max(lines - 1, 1) }).map((_: unknown, index: number) => {
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.line,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundTertiary,
|
||||
width: lineWidths[index % lineWidths.length],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
card: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
|
||||
@@ -28,8 +28,8 @@ export default function StateBadge({
|
||||
muted: theme.colors.stateMuted,
|
||||
};
|
||||
|
||||
const color = colorMap[state];
|
||||
const displayLabel = label || state;
|
||||
const color: string = colorMap[state];
|
||||
const displayLabel: string = label || state;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -41,19 +41,14 @@ export default function StateBadge({
|
||||
]}
|
||||
>
|
||||
<View style={[styles.dot, { backgroundColor: color }]} />
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.text, { color: theme.colors.textPrimary }]}>
|
||||
{displayLabel.charAt(0).toUpperCase() + displayLabel.slice(1)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
StyleSheet,
|
||||
Animated,
|
||||
PanResponder,
|
||||
type GestureResponderEvent,
|
||||
type PanResponderGestureState,
|
||||
type PanResponderInstance,
|
||||
} from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
import { useHaptics } from "../hooks/useHaptics";
|
||||
@@ -21,7 +24,7 @@ interface SwipeableCardProps {
|
||||
rightAction?: SwipeAction;
|
||||
}
|
||||
|
||||
const SWIPE_THRESHOLD = 80;
|
||||
const SWIPE_THRESHOLD: number = 80;
|
||||
|
||||
export default function SwipeableCard({
|
||||
children,
|
||||
@@ -30,8 +33,8 @@ export default function SwipeableCard({
|
||||
}: SwipeableCardProps): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const { mediumImpact } = useHaptics();
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const hasTriggeredHaptic = useRef(false);
|
||||
const translateX: Animated.Value = useRef(new Animated.Value(0)).current;
|
||||
const hasTriggeredHaptic: React.MutableRefObject<boolean> = useRef(false);
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
@@ -90,20 +93,14 @@ export default function SwipeableCard({
|
||||
<View style={styles.actionsContainer}>
|
||||
{leftAction ? (
|
||||
<View
|
||||
style={[
|
||||
styles.actionLeft,
|
||||
{ backgroundColor: leftAction.color },
|
||||
]}
|
||||
style={[styles.actionLeft, { backgroundColor: leftAction.color }]}
|
||||
>
|
||||
<Text style={styles.actionText}>{leftAction.label}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{rightAction ? (
|
||||
<View
|
||||
style={[
|
||||
styles.actionRight,
|
||||
{ backgroundColor: rightAction.color },
|
||||
]}
|
||||
style={[styles.actionRight, { backgroundColor: rightAction.color }]}
|
||||
>
|
||||
<Text style={styles.actionText}>{rightAction.label}</Text>
|
||||
</View>
|
||||
|
||||
@@ -8,23 +8,29 @@ import {
|
||||
export function useAlertDetail(projectId: string, alertId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["alert", projectId, alertId],
|
||||
queryFn: () => fetchAlertById(projectId, alertId),
|
||||
enabled: !!projectId && !!alertId,
|
||||
queryFn: () => {
|
||||
return fetchAlertById(projectId, alertId);
|
||||
},
|
||||
enabled: Boolean(projectId) && Boolean(alertId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertStates(projectId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["alert-states", projectId],
|
||||
queryFn: () => fetchAlertStates(projectId),
|
||||
enabled: !!projectId,
|
||||
queryFn: () => {
|
||||
return fetchAlertStates(projectId);
|
||||
},
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertStateTimeline(projectId: string, alertId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["alert-state-timeline", projectId, alertId],
|
||||
queryFn: () => fetchAlertStateTimeline(projectId, alertId),
|
||||
enabled: !!projectId && !!alertId,
|
||||
queryFn: () => {
|
||||
return fetchAlertStateTimeline(projectId, alertId);
|
||||
},
|
||||
enabled: Boolean(projectId) && Boolean(alertId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,22 +6,23 @@ import {
|
||||
fetchAlertEpisodeNotes,
|
||||
} from "../api/alertEpisodes";
|
||||
|
||||
export function useAlertEpisodeDetail(
|
||||
projectId: string,
|
||||
episodeId: string,
|
||||
) {
|
||||
export function useAlertEpisodeDetail(projectId: string, episodeId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["alert-episode", projectId, episodeId],
|
||||
queryFn: () => fetchAlertEpisodeById(projectId, episodeId),
|
||||
enabled: !!projectId && !!episodeId,
|
||||
queryFn: () => {
|
||||
return fetchAlertEpisodeById(projectId, episodeId);
|
||||
},
|
||||
enabled: Boolean(projectId) && Boolean(episodeId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertEpisodeStates(projectId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["alert-states", projectId],
|
||||
queryFn: () => fetchAlertEpisodeStates(projectId),
|
||||
enabled: !!projectId,
|
||||
queryFn: () => {
|
||||
return fetchAlertEpisodeStates(projectId);
|
||||
},
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,18 +32,19 @@ export function useAlertEpisodeStateTimeline(
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["alert-episode-state-timeline", projectId, episodeId],
|
||||
queryFn: () => fetchAlertEpisodeStateTimeline(projectId, episodeId),
|
||||
enabled: !!projectId && !!episodeId,
|
||||
queryFn: () => {
|
||||
return fetchAlertEpisodeStateTimeline(projectId, episodeId);
|
||||
},
|
||||
enabled: Boolean(projectId) && Boolean(episodeId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertEpisodeNotes(
|
||||
projectId: string,
|
||||
episodeId: string,
|
||||
) {
|
||||
export function useAlertEpisodeNotes(projectId: string, episodeId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["alert-episode-notes", projectId, episodeId],
|
||||
queryFn: () => fetchAlertEpisodeNotes(projectId, episodeId),
|
||||
enabled: !!projectId && !!episodeId,
|
||||
queryFn: () => {
|
||||
return fetchAlertEpisodeNotes(projectId, episodeId);
|
||||
},
|
||||
enabled: Boolean(projectId) && Boolean(episodeId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ export function useAlertEpisodes(
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["alert-episodes", projectId, skip, limit],
|
||||
queryFn: () => fetchAlertEpisodes(projectId, { skip, limit }),
|
||||
enabled: !!projectId,
|
||||
queryFn: () => {
|
||||
return fetchAlertEpisodes(projectId, { skip, limit });
|
||||
},
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +26,6 @@ export function useUnresolvedAlertEpisodeCount(projectId: string) {
|
||||
});
|
||||
return response.count;
|
||||
},
|
||||
enabled: !!projectId,
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { fetchAlertNotes } from "../api/alertNotes";
|
||||
export function useAlertNotes(projectId: string, alertId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["alert-notes", projectId, alertId],
|
||||
queryFn: () => fetchAlertNotes(projectId, alertId),
|
||||
enabled: !!projectId && !!alertId,
|
||||
queryFn: () => {
|
||||
return fetchAlertNotes(projectId, alertId);
|
||||
},
|
||||
enabled: Boolean(projectId) && Boolean(alertId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ export function useAlerts(
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["alerts", projectId, skip, limit],
|
||||
queryFn: () => fetchAlerts(projectId, { skip, limit }),
|
||||
enabled: !!projectId,
|
||||
queryFn: () => {
|
||||
return fetchAlerts(projectId, { skip, limit });
|
||||
},
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +26,6 @@ export function useUnresolvedAlertCount(projectId: string) {
|
||||
});
|
||||
return response.count;
|
||||
},
|
||||
enabled: !!projectId,
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import React, {
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { getTokens, clearTokens } from "../storage/keychain";
|
||||
import { getTokens } from "../storage/keychain";
|
||||
import { hasServerUrl } from "../storage/serverUrl";
|
||||
import {
|
||||
login as apiLogin,
|
||||
@@ -33,7 +33,9 @@ interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps): React.JSX.Element {
|
||||
export function AuthProvider({
|
||||
children,
|
||||
}: AuthProviderProps): React.JSX.Element {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [needsServerUrl, setNeedsServerUrl] = useState(false);
|
||||
|
||||
@@ -8,16 +8,20 @@ import {
|
||||
export function useIncidentDetail(projectId: string, incidentId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["incident", projectId, incidentId],
|
||||
queryFn: () => fetchIncidentById(projectId, incidentId),
|
||||
enabled: !!projectId && !!incidentId,
|
||||
queryFn: () => {
|
||||
return fetchIncidentById(projectId, incidentId);
|
||||
},
|
||||
enabled: Boolean(projectId) && Boolean(incidentId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIncidentStates(projectId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["incident-states", projectId],
|
||||
queryFn: () => fetchIncidentStates(projectId),
|
||||
enabled: !!projectId,
|
||||
queryFn: () => {
|
||||
return fetchIncidentStates(projectId);
|
||||
},
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,7 +31,9 @@ export function useIncidentStateTimeline(
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["incident-state-timeline", projectId, incidentId],
|
||||
queryFn: () => fetchIncidentStateTimeline(projectId, incidentId),
|
||||
enabled: !!projectId && !!incidentId,
|
||||
queryFn: () => {
|
||||
return fetchIncidentStateTimeline(projectId, incidentId);
|
||||
},
|
||||
enabled: Boolean(projectId) && Boolean(incidentId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,22 +6,23 @@ import {
|
||||
fetchIncidentEpisodeNotes,
|
||||
} from "../api/incidentEpisodes";
|
||||
|
||||
export function useIncidentEpisodeDetail(
|
||||
projectId: string,
|
||||
episodeId: string,
|
||||
) {
|
||||
export function useIncidentEpisodeDetail(projectId: string, episodeId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["incident-episode", projectId, episodeId],
|
||||
queryFn: () => fetchIncidentEpisodeById(projectId, episodeId),
|
||||
enabled: !!projectId && !!episodeId,
|
||||
queryFn: () => {
|
||||
return fetchIncidentEpisodeById(projectId, episodeId);
|
||||
},
|
||||
enabled: Boolean(projectId) && Boolean(episodeId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIncidentEpisodeStates(projectId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["incident-states", projectId],
|
||||
queryFn: () => fetchIncidentEpisodeStates(projectId),
|
||||
enabled: !!projectId,
|
||||
queryFn: () => {
|
||||
return fetchIncidentEpisodeStates(projectId);
|
||||
},
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,18 +32,19 @@ export function useIncidentEpisodeStateTimeline(
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["incident-episode-state-timeline", projectId, episodeId],
|
||||
queryFn: () => fetchIncidentEpisodeStateTimeline(projectId, episodeId),
|
||||
enabled: !!projectId && !!episodeId,
|
||||
queryFn: () => {
|
||||
return fetchIncidentEpisodeStateTimeline(projectId, episodeId);
|
||||
},
|
||||
enabled: Boolean(projectId) && Boolean(episodeId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIncidentEpisodeNotes(
|
||||
projectId: string,
|
||||
episodeId: string,
|
||||
) {
|
||||
export function useIncidentEpisodeNotes(projectId: string, episodeId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["incident-episode-notes", projectId, episodeId],
|
||||
queryFn: () => fetchIncidentEpisodeNotes(projectId, episodeId),
|
||||
enabled: !!projectId && !!episodeId,
|
||||
queryFn: () => {
|
||||
return fetchIncidentEpisodeNotes(projectId, episodeId);
|
||||
},
|
||||
enabled: Boolean(projectId) && Boolean(episodeId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ export function useIncidentEpisodes(
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["incident-episodes", projectId, skip, limit],
|
||||
queryFn: () => fetchIncidentEpisodes(projectId, { skip, limit }),
|
||||
enabled: !!projectId,
|
||||
queryFn: () => {
|
||||
return fetchIncidentEpisodes(projectId, { skip, limit });
|
||||
},
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +26,6 @@ export function useUnresolvedIncidentEpisodeCount(projectId: string) {
|
||||
});
|
||||
return response.count;
|
||||
},
|
||||
enabled: !!projectId,
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { fetchIncidentNotes } from "../api/incidentNotes";
|
||||
export function useIncidentNotes(projectId: string, incidentId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["incident-notes", projectId, incidentId],
|
||||
queryFn: () => fetchIncidentNotes(projectId, incidentId),
|
||||
enabled: !!projectId && !!incidentId,
|
||||
queryFn: () => {
|
||||
return fetchIncidentNotes(projectId, incidentId);
|
||||
},
|
||||
enabled: Boolean(projectId) && Boolean(incidentId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ export function useIncidents(
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["incidents", projectId, skip, limit],
|
||||
queryFn: () => fetchIncidents(projectId, { skip, limit }),
|
||||
enabled: !!projectId,
|
||||
queryFn: () => {
|
||||
return fetchIncidents(projectId, { skip, limit });
|
||||
},
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +26,6 @@ export function useUnresolvedIncidentCount(projectId: string) {
|
||||
});
|
||||
return response.count;
|
||||
},
|
||||
enabled: !!projectId,
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ interface ProjectContextValue {
|
||||
clearProject: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ProjectContext = createContext<ProjectContextValue | undefined>(undefined);
|
||||
const ProjectContext = createContext<ProjectContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface ProjectProviderProps {
|
||||
children: ReactNode;
|
||||
@@ -45,9 +47,9 @@ export function ProjectProvider({
|
||||
// Try to restore previously selected project
|
||||
const savedId = await AsyncStorage.getItem(PROJECT_STORAGE_KEY);
|
||||
if (savedId) {
|
||||
const saved = response.data.find(
|
||||
(p: ProjectItem) => p._id === savedId,
|
||||
);
|
||||
const saved = response.data.find((p: ProjectItem) => {
|
||||
return p._id === savedId;
|
||||
});
|
||||
if (saved) {
|
||||
setSelectedProject(saved);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,7 @@ import { useProject } from "./useProject";
|
||||
|
||||
const PUSH_TOKEN_KEY = "oneuptime_expo_push_token";
|
||||
|
||||
export function usePushNotifications(
|
||||
navigationRef: unknown,
|
||||
): void {
|
||||
export function usePushNotifications(navigationRef: unknown): void {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { projectList } = useProject();
|
||||
const responseListenerRef = useRef<Subscription | null>(null);
|
||||
@@ -79,10 +77,11 @@ export function usePushNotifications(
|
||||
|
||||
// Set up notification listeners
|
||||
useEffect(() => {
|
||||
receivedListenerRef.current =
|
||||
Notifications.addNotificationReceivedListener((_notification) => {
|
||||
receivedListenerRef.current = Notifications.addNotificationReceivedListener(
|
||||
(_notification) => {
|
||||
// Foreground notification received — handler in setup.ts shows it
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
responseListenerRef.current =
|
||||
Notifications.addNotificationResponseReceivedListener(
|
||||
|
||||
@@ -9,7 +9,7 @@ import AlertEpisodesStackNavigator from "./AlertEpisodesStackNavigator";
|
||||
import SettingsStackNavigator from "./SettingsStackNavigator";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||
const Tab: ReturnType<typeof createBottomTabNavigator<MainTabParamList>> = createBottomTabNavigator<MainTabParamList>();
|
||||
|
||||
export default function MainTabNavigator(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
@@ -119,7 +119,9 @@ export default function RootNavigator(): React.JSX.Element {
|
||||
if (biometric.isEnabled && !biometricPassed) {
|
||||
return (
|
||||
<BiometricLockScreen
|
||||
onSuccess={() => setBiometricPassed(true)}
|
||||
onSuccess={() => {
|
||||
return setBiometricPassed(true);
|
||||
}}
|
||||
biometricType={biometric.biometricType}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,13 +5,15 @@ import { Platform } from "react-native";
|
||||
|
||||
// Show notifications when app is in foreground
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
handleNotification: async () => {
|
||||
return {
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export async function setupNotificationChannels(): Promise<void> {
|
||||
@@ -24,8 +26,7 @@ export async function setupNotificationChannels(): Promise<void> {
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
sound: "default",
|
||||
vibrationPattern: [0, 500, 250, 500],
|
||||
lockscreenVisibility:
|
||||
Notifications.AndroidNotificationVisibility.PUBLIC,
|
||||
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
|
||||
});
|
||||
|
||||
await Notifications.setNotificationChannelAsync("oncall_high", {
|
||||
@@ -80,8 +81,7 @@ export async function requestPermissionsAndGetToken(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { status: existingStatus } =
|
||||
await Notifications.getPermissionsAsync();
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||
let finalStatus = existingStatus;
|
||||
|
||||
if (existingStatus !== "granted") {
|
||||
|
||||
@@ -23,21 +23,24 @@ import { createAlertNote } from "../api/alertNotes";
|
||||
import { rgbToHex } from "../utils/color";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
import type { AlertsStackParamList } from "../navigation/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
AlertState,
|
||||
StateTimelineItem,
|
||||
NoteItem,
|
||||
} from "../api/types";
|
||||
import AddNoteModal from "../components/AddNoteModal";
|
||||
import SkeletonCard from "../components/SkeletonCard";
|
||||
import { useHaptics } from "../hooks/useHaptics";
|
||||
|
||||
type Props = NativeStackScreenProps<AlertsStackParamList, "AlertDetail">;
|
||||
|
||||
export default function AlertDetailScreen({
|
||||
route,
|
||||
}: Props): React.JSX.Element {
|
||||
export default function AlertDetailScreen({ route }: Props): React.JSX.Element {
|
||||
const { alertId } = route.params;
|
||||
const { theme } = useTheme();
|
||||
const { selectedProject } = useProject();
|
||||
const projectId = selectedProject?._id ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
const projectId: string = selectedProject?._id ?? "";
|
||||
const queryClient: QueryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: alert,
|
||||
@@ -45,32 +48,34 @@ export default function AlertDetailScreen({
|
||||
refetch: refetchAlert,
|
||||
} = useAlertDetail(projectId, alertId);
|
||||
const { data: states } = useAlertStates(projectId);
|
||||
const {
|
||||
data: timeline,
|
||||
refetch: refetchTimeline,
|
||||
} = useAlertStateTimeline(projectId, alertId);
|
||||
const {
|
||||
data: notes,
|
||||
refetch: refetchNotes,
|
||||
} = useAlertNotes(projectId, alertId);
|
||||
const { data: timeline, refetch: refetchTimeline } = useAlertStateTimeline(
|
||||
projectId,
|
||||
alertId,
|
||||
);
|
||||
const { data: notes, refetch: refetchNotes } = useAlertNotes(
|
||||
projectId,
|
||||
alertId,
|
||||
);
|
||||
|
||||
const { successFeedback, errorFeedback } = useHaptics();
|
||||
const [changingState, setChangingState] = useState(false);
|
||||
const [noteModalVisible, setNoteModalVisible] = useState(false);
|
||||
const [submittingNote, setSubmittingNote] = useState(false);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
const onRefresh: () => Promise<void> = useCallback(async () => {
|
||||
await Promise.all([refetchAlert(), refetchTimeline(), refetchNotes()]);
|
||||
}, [refetchAlert, refetchTimeline, refetchNotes]);
|
||||
|
||||
const handleStateChange = useCallback(
|
||||
const handleStateChange: (stateId: string, stateName: string) => Promise<void> = useCallback(
|
||||
async (stateId: string, stateName: string) => {
|
||||
if (!alert) {
|
||||
return;
|
||||
}
|
||||
const queryKey = ["alert", projectId, alertId];
|
||||
const previousData = queryClient.getQueryData(queryKey);
|
||||
const newState = states?.find((s) => s._id === stateId);
|
||||
const queryKey: string[] = ["alert", projectId, alertId];
|
||||
const previousData: unknown = queryClient.getQueryData(queryKey);
|
||||
const newState: AlertState | undefined = states?.find((s: AlertState) => {
|
||||
return s._id === stateId;
|
||||
});
|
||||
if (newState) {
|
||||
queryClient.setQueryData(queryKey, {
|
||||
...alert,
|
||||
@@ -95,10 +100,18 @@ export default function AlertDetailScreen({
|
||||
setChangingState(false);
|
||||
}
|
||||
},
|
||||
[projectId, alertId, alert, states, refetchAlert, refetchTimeline, queryClient],
|
||||
[
|
||||
projectId,
|
||||
alertId,
|
||||
alert,
|
||||
states,
|
||||
refetchAlert,
|
||||
refetchTimeline,
|
||||
queryClient,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAddNote = useCallback(
|
||||
const handleAddNote: (noteText: string) => Promise<void> = useCallback(
|
||||
async (noteText: string) => {
|
||||
setSubmittingNote(true);
|
||||
try {
|
||||
@@ -117,9 +130,7 @@ export default function AlertDetailScreen({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{ flex: 1, backgroundColor: theme.colors.backgroundPrimary },
|
||||
]}
|
||||
style={[{ flex: 1, backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<SkeletonCard variant="detail" />
|
||||
</View>
|
||||
@@ -146,21 +157,25 @@ export default function AlertDetailScreen({
|
||||
);
|
||||
}
|
||||
|
||||
const stateColor = alert.currentAlertState?.color
|
||||
const stateColor: string = alert.currentAlertState?.color
|
||||
? rgbToHex(alert.currentAlertState.color)
|
||||
: theme.colors.textTertiary;
|
||||
|
||||
const severityColor = alert.alertSeverity?.color
|
||||
const severityColor: string = alert.alertSeverity?.color
|
||||
? rgbToHex(alert.alertSeverity.color)
|
||||
: theme.colors.textTertiary;
|
||||
|
||||
// Find acknowledge and resolve states from fetched state definitions
|
||||
const acknowledgeState = states?.find((s) => s.isAcknowledgedState);
|
||||
const resolveState = states?.find((s) => s.isResolvedState);
|
||||
const acknowledgeState: AlertState | undefined = states?.find((s: AlertState) => {
|
||||
return s.isAcknowledgedState;
|
||||
});
|
||||
const resolveState: AlertState | undefined = states?.find((s: AlertState) => {
|
||||
return s.isResolvedState;
|
||||
});
|
||||
|
||||
const currentStateId = alert.currentAlertState?._id;
|
||||
const isResolved = resolveState?._id === currentStateId;
|
||||
const isAcknowledged = acknowledgeState?._id === currentStateId;
|
||||
const currentStateId: string | undefined = alert.currentAlertState?._id;
|
||||
const isResolved: boolean = resolveState?._id === currentStateId;
|
||||
const isAcknowledged: boolean = acknowledgeState?._id === currentStateId;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -194,7 +209,9 @@ export default function AlertDetailScreen({
|
||||
]}
|
||||
>
|
||||
<View style={[styles.dot, { backgroundColor: stateColor }]} />
|
||||
<Text style={[styles.badgeText, { color: theme.colors.textPrimary }]}>
|
||||
<Text
|
||||
style={[styles.badgeText, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{alert.currentAlertState.name}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -215,10 +232,7 @@ export default function AlertDetailScreen({
|
||||
{alert.description ? (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
Description
|
||||
</Text>
|
||||
@@ -266,12 +280,18 @@ export default function AlertDetailScreen({
|
||||
{alert.monitor ? (
|
||||
<View style={styles.detailRow}>
|
||||
<Text
|
||||
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
|
||||
style={[
|
||||
styles.detailLabel,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
Monitor
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.detailValue, { color: theme.colors.textPrimary }]}
|
||||
style={[
|
||||
styles.detailValue,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{alert.monitor.name}
|
||||
</Text>
|
||||
@@ -284,10 +304,7 @@ export default function AlertDetailScreen({
|
||||
{!isResolved ? (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
Actions
|
||||
</Text>
|
||||
@@ -298,18 +315,21 @@ export default function AlertDetailScreen({
|
||||
styles.actionButton,
|
||||
{ backgroundColor: theme.colors.stateAcknowledged },
|
||||
]}
|
||||
onPress={() =>
|
||||
handleStateChange(
|
||||
onPress={() => {
|
||||
return handleStateChange(
|
||||
acknowledgeState._id,
|
||||
acknowledgeState.name,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
disabled={changingState}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Acknowledge alert"
|
||||
>
|
||||
{changingState ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.textInverse} />
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.textInverse}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
@@ -329,15 +349,18 @@ export default function AlertDetailScreen({
|
||||
styles.actionButton,
|
||||
{ backgroundColor: theme.colors.stateResolved },
|
||||
]}
|
||||
onPress={() =>
|
||||
handleStateChange(resolveState._id, resolveState.name)
|
||||
}
|
||||
onPress={() => {
|
||||
return handleStateChange(resolveState._id, resolveState.name);
|
||||
}}
|
||||
disabled={changingState}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Resolve alert"
|
||||
>
|
||||
{changingState ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.textInverse} />
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.textInverse}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
@@ -358,15 +381,12 @@ export default function AlertDetailScreen({
|
||||
{timeline && timeline.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
State Timeline
|
||||
</Text>
|
||||
{timeline.map((entry) => {
|
||||
const entryColor = entry.alertState?.color
|
||||
{timeline.map((entry: StateTimelineItem) => {
|
||||
const entryColor: string = entry.alertState?.color
|
||||
? rgbToHex(entry.alertState.color)
|
||||
: theme.colors.textTertiary;
|
||||
return (
|
||||
@@ -423,7 +443,9 @@ export default function AlertDetailScreen({
|
||||
styles.addNoteButton,
|
||||
{ backgroundColor: theme.colors.actionPrimary },
|
||||
]}
|
||||
onPress={() => setNoteModalVisible(true)}
|
||||
onPress={() => {
|
||||
return setNoteModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
@@ -437,47 +459,49 @@ export default function AlertDetailScreen({
|
||||
</View>
|
||||
|
||||
{notes && notes.length > 0
|
||||
? notes.map((note) => (
|
||||
<View
|
||||
key={note._id}
|
||||
style={[
|
||||
styles.noteCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
? notes.map((note: NoteItem) => {
|
||||
return (
|
||||
<View
|
||||
key={note._id}
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textPrimary },
|
||||
styles.noteCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{note.note}
|
||||
</Text>
|
||||
<View style={styles.noteMeta}>
|
||||
{note.createdByUser ? (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{note.note}
|
||||
</Text>
|
||||
<View style={styles.noteMeta}>
|
||||
{note.createdByUser ? (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{note.createdByUser.name}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{note.createdByUser.name}
|
||||
{formatDateTime(note.createdAt)}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{formatDateTime(note.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
);
|
||||
})
|
||||
: null}
|
||||
|
||||
{notes && notes.length === 0 ? (
|
||||
@@ -494,7 +518,9 @@ export default function AlertDetailScreen({
|
||||
|
||||
<AddNoteModal
|
||||
visible={noteModalVisible}
|
||||
onClose={() => setNoteModalVisible(false)}
|
||||
onClose={() => {
|
||||
return setNoteModalVisible(false);
|
||||
}}
|
||||
onSubmit={handleAddNote}
|
||||
isSubmitting={submittingNote}
|
||||
/>
|
||||
@@ -502,7 +528,7 @@ export default function AlertDetailScreen({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -25,7 +25,12 @@ import {
|
||||
import { rgbToHex } from "../utils/color";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
import type { AlertEpisodesStackParamList } from "../navigation/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
AlertState,
|
||||
StateTimelineItem,
|
||||
NoteItem,
|
||||
} from "../api/types";
|
||||
import AddNoteModal from "../components/AddNoteModal";
|
||||
import SkeletonCard from "../components/SkeletonCard";
|
||||
import { useHaptics } from "../hooks/useHaptics";
|
||||
@@ -41,8 +46,8 @@ export default function AlertEpisodeDetailScreen({
|
||||
const { episodeId } = route.params;
|
||||
const { theme } = useTheme();
|
||||
const { selectedProject } = useProject();
|
||||
const projectId = selectedProject?._id ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
const projectId: string = selectedProject?._id ?? "";
|
||||
const queryClient: QueryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: episode,
|
||||
@@ -50,32 +55,32 @@ export default function AlertEpisodeDetailScreen({
|
||||
refetch: refetchEpisode,
|
||||
} = useAlertEpisodeDetail(projectId, episodeId);
|
||||
const { data: states } = useAlertEpisodeStates(projectId);
|
||||
const {
|
||||
data: timeline,
|
||||
refetch: refetchTimeline,
|
||||
} = useAlertEpisodeStateTimeline(projectId, episodeId);
|
||||
const {
|
||||
data: notes,
|
||||
refetch: refetchNotes,
|
||||
} = useAlertEpisodeNotes(projectId, episodeId);
|
||||
const { data: timeline, refetch: refetchTimeline } =
|
||||
useAlertEpisodeStateTimeline(projectId, episodeId);
|
||||
const { data: notes, refetch: refetchNotes } = useAlertEpisodeNotes(
|
||||
projectId,
|
||||
episodeId,
|
||||
);
|
||||
|
||||
const { successFeedback, errorFeedback } = useHaptics();
|
||||
const [changingState, setChangingState] = useState(false);
|
||||
const [noteModalVisible, setNoteModalVisible] = useState(false);
|
||||
const [submittingNote, setSubmittingNote] = useState(false);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
const onRefresh: () => Promise<void> = useCallback(async () => {
|
||||
await Promise.all([refetchEpisode(), refetchTimeline(), refetchNotes()]);
|
||||
}, [refetchEpisode, refetchTimeline, refetchNotes]);
|
||||
|
||||
const handleStateChange = useCallback(
|
||||
const handleStateChange: (stateId: string, stateName: string) => Promise<void> = useCallback(
|
||||
async (stateId: string, stateName: string) => {
|
||||
if (!episode) {
|
||||
return;
|
||||
}
|
||||
const queryKey = ["alert-episode", projectId, episodeId];
|
||||
const previousData = queryClient.getQueryData(queryKey);
|
||||
const newState = states?.find((s) => s._id === stateId);
|
||||
const queryKey: string[] = ["alert-episode", projectId, episodeId];
|
||||
const previousData: unknown = queryClient.getQueryData(queryKey);
|
||||
const newState: AlertState | undefined = states?.find((s: AlertState) => {
|
||||
return s._id === stateId;
|
||||
});
|
||||
if (newState) {
|
||||
queryClient.setQueryData(queryKey, {
|
||||
...episode,
|
||||
@@ -113,7 +118,7 @@ export default function AlertEpisodeDetailScreen({
|
||||
],
|
||||
);
|
||||
|
||||
const handleAddNote = useCallback(
|
||||
const handleAddNote: (noteText: string) => Promise<void> = useCallback(
|
||||
async (noteText: string) => {
|
||||
setSubmittingNote(true);
|
||||
try {
|
||||
@@ -132,9 +137,7 @@ export default function AlertEpisodeDetailScreen({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{ flex: 1, backgroundColor: theme.colors.backgroundPrimary },
|
||||
]}
|
||||
style={[{ flex: 1, backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<SkeletonCard variant="detail" />
|
||||
</View>
|
||||
@@ -161,20 +164,24 @@ export default function AlertEpisodeDetailScreen({
|
||||
);
|
||||
}
|
||||
|
||||
const stateColor = episode.currentAlertState?.color
|
||||
const stateColor: string = episode.currentAlertState?.color
|
||||
? rgbToHex(episode.currentAlertState.color)
|
||||
: theme.colors.textTertiary;
|
||||
|
||||
const severityColor = episode.alertSeverity?.color
|
||||
const severityColor: string = episode.alertSeverity?.color
|
||||
? rgbToHex(episode.alertSeverity.color)
|
||||
: theme.colors.textTertiary;
|
||||
|
||||
const acknowledgeState = states?.find((s) => s.isAcknowledgedState);
|
||||
const resolveState = states?.find((s) => s.isResolvedState);
|
||||
const acknowledgeState: AlertState | undefined = states?.find((s: AlertState) => {
|
||||
return s.isAcknowledgedState;
|
||||
});
|
||||
const resolveState: AlertState | undefined = states?.find((s: AlertState) => {
|
||||
return s.isResolvedState;
|
||||
});
|
||||
|
||||
const currentStateId = episode.currentAlertState?._id;
|
||||
const isResolved = resolveState?._id === currentStateId;
|
||||
const isAcknowledged = acknowledgeState?._id === currentStateId;
|
||||
const currentStateId: string | undefined = episode.currentAlertState?._id;
|
||||
const isResolved: boolean = resolveState?._id === currentStateId;
|
||||
const isAcknowledged: boolean = acknowledgeState?._id === currentStateId;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -231,10 +238,7 @@ export default function AlertEpisodeDetailScreen({
|
||||
{episode.description ? (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
Description
|
||||
</Text>
|
||||
@@ -268,18 +272,12 @@ export default function AlertEpisodeDetailScreen({
|
||||
>
|
||||
<View style={styles.detailRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.detailLabel,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
|
||||
>
|
||||
Created
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.detailValue,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
style={[styles.detailValue, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{formatDateTime(episode.createdAt)}
|
||||
</Text>
|
||||
@@ -287,18 +285,12 @@ export default function AlertEpisodeDetailScreen({
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.detailLabel,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
|
||||
>
|
||||
Alerts
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.detailValue,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
style={[styles.detailValue, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{episode.alertCount ?? 0}
|
||||
</Text>
|
||||
@@ -310,10 +302,7 @@ export default function AlertEpisodeDetailScreen({
|
||||
{!isResolved ? (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
Actions
|
||||
</Text>
|
||||
@@ -324,12 +313,12 @@ export default function AlertEpisodeDetailScreen({
|
||||
styles.actionButton,
|
||||
{ backgroundColor: theme.colors.stateAcknowledged },
|
||||
]}
|
||||
onPress={() =>
|
||||
handleStateChange(
|
||||
onPress={() => {
|
||||
return handleStateChange(
|
||||
acknowledgeState._id,
|
||||
acknowledgeState.name,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
disabled={changingState}
|
||||
>
|
||||
{changingState ? (
|
||||
@@ -356,9 +345,9 @@ export default function AlertEpisodeDetailScreen({
|
||||
styles.actionButton,
|
||||
{ backgroundColor: theme.colors.stateResolved },
|
||||
]}
|
||||
onPress={() =>
|
||||
handleStateChange(resolveState._id, resolveState.name)
|
||||
}
|
||||
onPress={() => {
|
||||
return handleStateChange(resolveState._id, resolveState.name);
|
||||
}}
|
||||
disabled={changingState}
|
||||
>
|
||||
{changingState ? (
|
||||
@@ -386,15 +375,12 @@ export default function AlertEpisodeDetailScreen({
|
||||
{timeline && timeline.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
State Timeline
|
||||
</Text>
|
||||
{timeline.map((entry) => {
|
||||
const entryColor = entry.alertState?.color
|
||||
{timeline.map((entry: StateTimelineItem) => {
|
||||
const entryColor: string = entry.alertState?.color
|
||||
? rgbToHex(entry.alertState.color)
|
||||
: theme.colors.textTertiary;
|
||||
return (
|
||||
@@ -409,10 +395,7 @@ export default function AlertEpisodeDetailScreen({
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.timelineDot,
|
||||
{ backgroundColor: entryColor },
|
||||
]}
|
||||
style={[styles.timelineDot, { backgroundColor: entryColor }]}
|
||||
/>
|
||||
<View style={styles.timelineInfo}>
|
||||
<Text
|
||||
@@ -457,7 +440,9 @@ export default function AlertEpisodeDetailScreen({
|
||||
styles.addNoteButton,
|
||||
{ backgroundColor: theme.colors.actionPrimary },
|
||||
]}
|
||||
onPress={() => setNoteModalVisible(true)}
|
||||
onPress={() => {
|
||||
return setNoteModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
@@ -471,47 +456,49 @@ export default function AlertEpisodeDetailScreen({
|
||||
</View>
|
||||
|
||||
{notes && notes.length > 0
|
||||
? notes.map((note) => (
|
||||
<View
|
||||
key={note._id}
|
||||
style={[
|
||||
styles.noteCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
? notes.map((note: NoteItem) => {
|
||||
return (
|
||||
<View
|
||||
key={note._id}
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textPrimary },
|
||||
styles.noteCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{note.note}
|
||||
</Text>
|
||||
<View style={styles.noteMeta}>
|
||||
{note.createdByUser ? (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{note.note}
|
||||
</Text>
|
||||
<View style={styles.noteMeta}>
|
||||
{note.createdByUser ? (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{note.createdByUser.name}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{note.createdByUser.name}
|
||||
{formatDateTime(note.createdAt)}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{formatDateTime(note.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
);
|
||||
})
|
||||
: null}
|
||||
|
||||
{notes && notes.length === 0 ? (
|
||||
@@ -528,7 +515,9 @@ export default function AlertEpisodeDetailScreen({
|
||||
|
||||
<AddNoteModal
|
||||
visible={noteModalVisible}
|
||||
onClose={() => setNoteModalVisible(false)}
|
||||
onClose={() => {
|
||||
return setNoteModalVisible(false);
|
||||
}}
|
||||
onSubmit={handleAddNote}
|
||||
isSubmitting={submittingNote}
|
||||
/>
|
||||
@@ -536,7 +525,7 @@ export default function AlertEpisodeDetailScreen({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -19,7 +19,7 @@ import EmptyState from "../components/EmptyState";
|
||||
import type { AlertEpisodesStackParamList } from "../navigation/types";
|
||||
import type { AlertEpisodeItem } from "../api/types";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PAGE_SIZE: number = 20;
|
||||
|
||||
type NavProp = NativeStackNavigationProp<
|
||||
AlertEpisodesStackParamList,
|
||||
@@ -29,12 +29,12 @@ type NavProp = NativeStackNavigationProp<
|
||||
export default function AlertEpisodesScreen(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const { selectedProject } = useProject();
|
||||
const projectId = selectedProject?._id ?? "";
|
||||
const navigation = useNavigation<NavProp>();
|
||||
const projectId: string = selectedProject?._id ?? "";
|
||||
const navigation: NavProp = useNavigation<NavProp>();
|
||||
|
||||
const { lightImpact } = useHaptics();
|
||||
const [page, setPage] = useState(0);
|
||||
const skip = page * PAGE_SIZE;
|
||||
const skip: number = page * PAGE_SIZE;
|
||||
|
||||
const { data, isLoading, isError, refetch } = useAlertEpisodes(
|
||||
projectId,
|
||||
@@ -42,23 +42,25 @@ export default function AlertEpisodesScreen(): React.JSX.Element {
|
||||
PAGE_SIZE,
|
||||
);
|
||||
|
||||
const episodes = data?.data ?? [];
|
||||
const totalCount = data?.count ?? 0;
|
||||
const hasMore = skip + PAGE_SIZE < totalCount;
|
||||
const episodes: AlertEpisodeItem[] = data?.data ?? [];
|
||||
const totalCount: number = data?.count ?? 0;
|
||||
const hasMore: boolean = skip + PAGE_SIZE < totalCount;
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
const onRefresh: () => Promise<void> = useCallback(async () => {
|
||||
lightImpact();
|
||||
setPage(0);
|
||||
await refetch();
|
||||
}, [refetch, lightImpact]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
const loadMore: () => void = useCallback(() => {
|
||||
if (hasMore && !isLoading) {
|
||||
setPage((prev) => prev + 1);
|
||||
setPage((prev: number) => {
|
||||
return prev + 1;
|
||||
});
|
||||
}
|
||||
}, [hasMore, isLoading]);
|
||||
|
||||
const handlePress = useCallback(
|
||||
const handlePress: (episode: AlertEpisodeItem) => void = useCallback(
|
||||
(episode: AlertEpisodeItem) => {
|
||||
navigation.navigate("AlertEpisodeDetail", {
|
||||
episodeId: episode._id,
|
||||
@@ -105,7 +107,9 @@ export default function AlertEpisodesScreen(): React.JSX.Element {
|
||||
styles.retryButton,
|
||||
{ backgroundColor: theme.colors.actionPrimary },
|
||||
]}
|
||||
onPress={() => refetch()}
|
||||
onPress={() => {
|
||||
return refetch();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
@@ -129,17 +133,23 @@ export default function AlertEpisodesScreen(): React.JSX.Element {
|
||||
>
|
||||
<FlatList
|
||||
data={episodes}
|
||||
keyExtractor={(item) => item._id}
|
||||
keyExtractor={(item: AlertEpisodeItem) => {
|
||||
return item._id;
|
||||
}}
|
||||
contentContainerStyle={
|
||||
episodes.length === 0 ? styles.emptyContainer : styles.list
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<EpisodeCard
|
||||
episode={item}
|
||||
type="alert"
|
||||
onPress={() => handlePress(item)}
|
||||
/>
|
||||
)}
|
||||
renderItem={({ item }: { item: AlertEpisodeItem }) => {
|
||||
return (
|
||||
<EpisodeCard
|
||||
episode={item}
|
||||
type="alert"
|
||||
onPress={() => {
|
||||
return handlePress(item);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No alert episodes"
|
||||
@@ -157,7 +167,7 @@ export default function AlertEpisodesScreen(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -20,21 +20,21 @@ import SwipeableCard from "../components/SwipeableCard";
|
||||
import SkeletonCard from "../components/SkeletonCard";
|
||||
import EmptyState from "../components/EmptyState";
|
||||
import type { AlertsStackParamList } from "../navigation/types";
|
||||
import type { AlertItem } from "../api/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { AlertItem, AlertState } from "../api/types";
|
||||
import { QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PAGE_SIZE: number = 20;
|
||||
|
||||
type NavProp = NativeStackNavigationProp<AlertsStackParamList, "AlertsList">;
|
||||
|
||||
export default function AlertsScreen(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const { selectedProject } = useProject();
|
||||
const projectId = selectedProject?._id ?? "";
|
||||
const navigation = useNavigation<NavProp>();
|
||||
const projectId: string = selectedProject?._id ?? "";
|
||||
const navigation: NavProp = useNavigation<NavProp>();
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const skip = page * PAGE_SIZE;
|
||||
const skip: number = page * PAGE_SIZE;
|
||||
|
||||
const { data, isLoading, isError, refetch } = useAlerts(
|
||||
projectId,
|
||||
@@ -43,34 +43,38 @@ export default function AlertsScreen(): React.JSX.Element {
|
||||
);
|
||||
const { data: states } = useAlertStates(projectId);
|
||||
const { successFeedback, errorFeedback, lightImpact } = useHaptics();
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient: QueryClient = useQueryClient();
|
||||
|
||||
const acknowledgeState = states?.find((s) => s.isAcknowledgedState);
|
||||
const acknowledgeState: AlertState | undefined = states?.find((s: AlertState) => {
|
||||
return s.isAcknowledgedState;
|
||||
});
|
||||
|
||||
const alerts = data?.data ?? [];
|
||||
const totalCount = data?.count ?? 0;
|
||||
const hasMore = skip + PAGE_SIZE < totalCount;
|
||||
const alerts: AlertItem[] = data?.data ?? [];
|
||||
const totalCount: number = data?.count ?? 0;
|
||||
const hasMore: boolean = skip + PAGE_SIZE < totalCount;
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
const onRefresh: () => Promise<void> = useCallback(async () => {
|
||||
lightImpact();
|
||||
setPage(0);
|
||||
await refetch();
|
||||
}, [refetch, lightImpact]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
const loadMore: () => void = useCallback(() => {
|
||||
if (hasMore && !isLoading) {
|
||||
setPage((prev) => prev + 1);
|
||||
setPage((prev: number) => {
|
||||
return prev + 1;
|
||||
});
|
||||
}
|
||||
}, [hasMore, isLoading]);
|
||||
|
||||
const handlePress = useCallback(
|
||||
const handlePress: (alert: AlertItem) => void = useCallback(
|
||||
(alert: AlertItem) => {
|
||||
navigation.navigate("AlertDetail", { alertId: alert._id });
|
||||
},
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const handleAcknowledge = useCallback(
|
||||
const handleAcknowledge: (alert: AlertItem) => Promise<void> = useCallback(
|
||||
async (alert: AlertItem) => {
|
||||
if (!acknowledgeState) {
|
||||
return;
|
||||
@@ -84,7 +88,14 @@ export default function AlertsScreen(): React.JSX.Element {
|
||||
await errorFeedback();
|
||||
}
|
||||
},
|
||||
[projectId, acknowledgeState, successFeedback, errorFeedback, refetch, queryClient],
|
||||
[
|
||||
projectId,
|
||||
acknowledgeState,
|
||||
successFeedback,
|
||||
errorFeedback,
|
||||
refetch,
|
||||
queryClient,
|
||||
],
|
||||
);
|
||||
|
||||
if (isLoading && alerts.length === 0) {
|
||||
@@ -125,7 +136,9 @@ export default function AlertsScreen(): React.JSX.Element {
|
||||
styles.retryButton,
|
||||
{ backgroundColor: theme.colors.actionPrimary },
|
||||
]}
|
||||
onPress={() => refetch()}
|
||||
onPress={() => {
|
||||
return refetch();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
@@ -149,26 +162,37 @@ export default function AlertsScreen(): React.JSX.Element {
|
||||
>
|
||||
<FlatList
|
||||
data={alerts}
|
||||
keyExtractor={(item) => item._id}
|
||||
keyExtractor={(item: AlertItem) => {
|
||||
return item._id;
|
||||
}}
|
||||
contentContainerStyle={
|
||||
alerts.length === 0 ? styles.emptyContainer : styles.list
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<SwipeableCard
|
||||
rightAction={
|
||||
acknowledgeState &&
|
||||
item.currentAlertState?._id !== acknowledgeState._id
|
||||
? {
|
||||
label: "Acknowledge",
|
||||
color: "#2EA043",
|
||||
onAction: () => handleAcknowledge(item),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<AlertCard alert={item} onPress={() => handlePress(item)} />
|
||||
</SwipeableCard>
|
||||
)}
|
||||
renderItem={({ item }: { item: AlertItem }) => {
|
||||
return (
|
||||
<SwipeableCard
|
||||
rightAction={
|
||||
acknowledgeState &&
|
||||
item.currentAlertState?._id !== acknowledgeState._id
|
||||
? {
|
||||
label: "Acknowledge",
|
||||
color: "#2EA043",
|
||||
onAction: () => {
|
||||
return handleAcknowledge(item);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<AlertCard
|
||||
alert={item}
|
||||
onPress={() => {
|
||||
return handlePress(item);
|
||||
}}
|
||||
/>
|
||||
</SwipeableCard>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No active alerts"
|
||||
@@ -186,7 +210,7 @@ export default function AlertsScreen(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -45,7 +45,10 @@ export default function BiometricLockScreen({
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[styles.lockBody, { backgroundColor: theme.colors.textTertiary }]}
|
||||
style={[
|
||||
styles.lockBody,
|
||||
{ backgroundColor: theme.colors.textTertiary },
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
@@ -58,7 +61,11 @@ export default function BiometricLockScreen({
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.titleMedium,
|
||||
{ color: theme.colors.textPrimary, marginTop: 24, textAlign: "center" },
|
||||
{
|
||||
color: theme.colors.textPrimary,
|
||||
marginTop: 24,
|
||||
textAlign: "center",
|
||||
},
|
||||
]}
|
||||
>
|
||||
OneUptime is Locked
|
||||
|
||||
@@ -58,7 +58,7 @@ function StatCard({
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text style={[styles.cardCount, { color }]}>
|
||||
{isLoading ? "--" : (count ?? 0)}
|
||||
{isLoading ? "--" : count ?? 0}
|
||||
</Text>
|
||||
<Text style={[styles.cardLabel, { color: theme.colors.textSecondary }]}>
|
||||
{label}
|
||||
@@ -88,12 +88,7 @@ function QuickLink({ label, onPress }: QuickLinkProps): React.JSX.Element {
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.linkLabel,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.linkLabel, { color: theme.colors.textPrimary }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={[styles.chevron, { color: theme.colors.textTertiary }]}>
|
||||
@@ -183,14 +178,18 @@ export default function HomeScreen(): React.JSX.Element {
|
||||
label="Active Incidents"
|
||||
color={theme.colors.severityCritical}
|
||||
isLoading={loadingIncidents}
|
||||
onPress={() => navigation.navigate("Incidents")}
|
||||
onPress={() => {
|
||||
return navigation.navigate("Incidents");
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
count={alertCount}
|
||||
label="Active Alerts"
|
||||
color={theme.colors.severityMajor}
|
||||
isLoading={loadingAlerts}
|
||||
onPress={() => navigation.navigate("Alerts")}
|
||||
onPress={() => {
|
||||
return navigation.navigate("Alerts");
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -200,42 +199,51 @@ export default function HomeScreen(): React.JSX.Element {
|
||||
label="Inc Episodes"
|
||||
color={theme.colors.severityCritical}
|
||||
isLoading={loadingIncidentEpisodes}
|
||||
onPress={() => navigation.navigate("IncidentEpisodes")}
|
||||
onPress={() => {
|
||||
return navigation.navigate("IncidentEpisodes");
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
count={alertEpisodeCount}
|
||||
label="Alert Episodes"
|
||||
color={theme.colors.severityMajor}
|
||||
isLoading={loadingAlertEpisodes}
|
||||
onPress={() => navigation.navigate("AlertEpisodes")}
|
||||
onPress={() => {
|
||||
return navigation.navigate("AlertEpisodes");
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Quick Links */}
|
||||
<View style={styles.quickLinksSection}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
Quick Links
|
||||
</Text>
|
||||
<QuickLink
|
||||
label="View All Incidents"
|
||||
onPress={() => navigation.navigate("Incidents")}
|
||||
onPress={() => {
|
||||
return navigation.navigate("Incidents");
|
||||
}}
|
||||
/>
|
||||
<QuickLink
|
||||
label="View All Alerts"
|
||||
onPress={() => navigation.navigate("Alerts")}
|
||||
onPress={() => {
|
||||
return navigation.navigate("Alerts");
|
||||
}}
|
||||
/>
|
||||
<QuickLink
|
||||
label="Incident Episodes"
|
||||
onPress={() => navigation.navigate("IncidentEpisodes")}
|
||||
onPress={() => {
|
||||
return navigation.navigate("IncidentEpisodes");
|
||||
}}
|
||||
/>
|
||||
<QuickLink
|
||||
label="Alert Episodes"
|
||||
onPress={() => navigation.navigate("AlertEpisodes")}
|
||||
onPress={() => {
|
||||
return navigation.navigate("AlertEpisodes");
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
StyleSheet,
|
||||
} from "react-native";
|
||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { useTheme } from "../theme";
|
||||
import { useTheme, type Theme } from "../theme";
|
||||
import { useProject } from "../hooks/useProject";
|
||||
import {
|
||||
useIncidentDetail,
|
||||
@@ -23,10 +23,17 @@ import { createIncidentNote } from "../api/incidentNotes";
|
||||
import { rgbToHex } from "../utils/color";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
import type { IncidentsStackParamList } from "../navigation/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
import AddNoteModal from "../components/AddNoteModal";
|
||||
import SkeletonCard from "../components/SkeletonCard";
|
||||
import { useHaptics } from "../hooks/useHaptics";
|
||||
import type {
|
||||
IncidentItem,
|
||||
IncidentState,
|
||||
StateTimelineItem,
|
||||
NoteItem,
|
||||
NamedEntity,
|
||||
} from "../api/types";
|
||||
|
||||
type Props = NativeStackScreenProps<IncidentsStackParamList, "IncidentDetail">;
|
||||
|
||||
@@ -34,10 +41,10 @@ export default function IncidentDetailScreen({
|
||||
route,
|
||||
}: Props): React.JSX.Element {
|
||||
const { incidentId } = route.params;
|
||||
const { theme } = useTheme();
|
||||
const { theme }: { theme: Theme } = useTheme();
|
||||
const { selectedProject } = useProject();
|
||||
const projectId = selectedProject?._id ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
const projectId: string = selectedProject?._id ?? "";
|
||||
const queryClient: QueryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: incident,
|
||||
@@ -45,32 +52,40 @@ export default function IncidentDetailScreen({
|
||||
refetch: refetchIncident,
|
||||
} = useIncidentDetail(projectId, incidentId);
|
||||
const { data: states } = useIncidentStates(projectId);
|
||||
const {
|
||||
data: timeline,
|
||||
refetch: refetchTimeline,
|
||||
} = useIncidentStateTimeline(projectId, incidentId);
|
||||
const {
|
||||
data: notes,
|
||||
refetch: refetchNotes,
|
||||
} = useIncidentNotes(projectId, incidentId);
|
||||
const { data: timeline, refetch: refetchTimeline } = useIncidentStateTimeline(
|
||||
projectId,
|
||||
incidentId,
|
||||
);
|
||||
const { data: notes, refetch: refetchNotes } = useIncidentNotes(
|
||||
projectId,
|
||||
incidentId,
|
||||
);
|
||||
|
||||
const { successFeedback, errorFeedback } = useHaptics();
|
||||
const [changingState, setChangingState] = useState(false);
|
||||
const [noteModalVisible, setNoteModalVisible] = useState(false);
|
||||
const [submittingNote, setSubmittingNote] = useState(false);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
const onRefresh: () => Promise<void> = useCallback(async () => {
|
||||
await Promise.all([refetchIncident(), refetchTimeline(), refetchNotes()]);
|
||||
}, [refetchIncident, refetchTimeline, refetchNotes]);
|
||||
|
||||
const handleStateChange = useCallback(
|
||||
const handleStateChange: (
|
||||
stateId: string,
|
||||
stateName: string,
|
||||
) => Promise<void> = useCallback(
|
||||
async (stateId: string, stateName: string) => {
|
||||
if (!incident) {
|
||||
return;
|
||||
}
|
||||
const queryKey = ["incident", projectId, incidentId];
|
||||
const previousData = queryClient.getQueryData(queryKey);
|
||||
const newState = states?.find((s) => s._id === stateId);
|
||||
const queryKey: string[] = ["incident", projectId, incidentId];
|
||||
const previousData: IncidentItem | undefined =
|
||||
queryClient.getQueryData(queryKey);
|
||||
const newState: IncidentState | undefined = states?.find(
|
||||
(s: IncidentState) => {
|
||||
return s._id === stateId;
|
||||
},
|
||||
);
|
||||
if (newState) {
|
||||
queryClient.setQueryData(queryKey, {
|
||||
...incident,
|
||||
@@ -95,10 +110,18 @@ export default function IncidentDetailScreen({
|
||||
setChangingState(false);
|
||||
}
|
||||
},
|
||||
[projectId, incidentId, incident, states, refetchIncident, refetchTimeline, queryClient],
|
||||
[
|
||||
projectId,
|
||||
incidentId,
|
||||
incident,
|
||||
states,
|
||||
refetchIncident,
|
||||
refetchTimeline,
|
||||
queryClient,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAddNote = useCallback(
|
||||
const handleAddNote: (noteText: string) => Promise<void> = useCallback(
|
||||
async (noteText: string) => {
|
||||
setSubmittingNote(true);
|
||||
try {
|
||||
@@ -117,9 +140,7 @@ export default function IncidentDetailScreen({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{ flex: 1, backgroundColor: theme.colors.backgroundPrimary },
|
||||
]}
|
||||
style={[{ flex: 1, backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<SkeletonCard variant="detail" />
|
||||
</View>
|
||||
@@ -146,21 +167,29 @@ export default function IncidentDetailScreen({
|
||||
);
|
||||
}
|
||||
|
||||
const stateColor = incident.currentIncidentState?.color
|
||||
const stateColor: string = incident.currentIncidentState?.color
|
||||
? rgbToHex(incident.currentIncidentState.color)
|
||||
: theme.colors.textTertiary;
|
||||
|
||||
const severityColor = incident.incidentSeverity?.color
|
||||
const severityColor: string = incident.incidentSeverity?.color
|
||||
? rgbToHex(incident.incidentSeverity.color)
|
||||
: theme.colors.textTertiary;
|
||||
|
||||
// Find acknowledge and resolve states from fetched state definitions
|
||||
const acknowledgeState = states?.find((s) => s.isAcknowledgedState);
|
||||
const resolveState = states?.find((s) => s.isResolvedState);
|
||||
const acknowledgeState: IncidentState | undefined = states?.find(
|
||||
(s: IncidentState) => {
|
||||
return s.isAcknowledgedState;
|
||||
},
|
||||
);
|
||||
const resolveState: IncidentState | undefined = states?.find(
|
||||
(s: IncidentState) => {
|
||||
return s.isResolvedState;
|
||||
},
|
||||
);
|
||||
|
||||
const currentStateId = incident.currentIncidentState?._id;
|
||||
const isResolved = resolveState?._id === currentStateId;
|
||||
const isAcknowledged = acknowledgeState?._id === currentStateId;
|
||||
const currentStateId: string | undefined = incident.currentIncidentState?._id;
|
||||
const isResolved: boolean = resolveState?._id === currentStateId;
|
||||
const isAcknowledged: boolean = acknowledgeState?._id === currentStateId;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -171,12 +200,7 @@ export default function IncidentDetailScreen({
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<Text
|
||||
style={[
|
||||
styles.number,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.number, { color: theme.colors.textTertiary }]}>
|
||||
{incident.incidentNumberWithPrefix || `#${incident.incidentNumber}`}
|
||||
</Text>
|
||||
|
||||
@@ -199,7 +223,9 @@ export default function IncidentDetailScreen({
|
||||
]}
|
||||
>
|
||||
<View style={[styles.dot, { backgroundColor: stateColor }]} />
|
||||
<Text style={[styles.badgeText, { color: theme.colors.textPrimary }]}>
|
||||
<Text
|
||||
style={[styles.badgeText, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{incident.currentIncidentState.name}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -220,10 +246,7 @@ export default function IncidentDetailScreen({
|
||||
{incident.description ? (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
Description
|
||||
</Text>
|
||||
@@ -258,7 +281,10 @@ export default function IncidentDetailScreen({
|
||||
{incident.declaredAt ? (
|
||||
<View style={styles.detailRow}>
|
||||
<Text
|
||||
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
|
||||
style={[
|
||||
styles.detailLabel,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
Declared
|
||||
</Text>
|
||||
@@ -289,7 +315,10 @@ export default function IncidentDetailScreen({
|
||||
{incident.monitors?.length > 0 ? (
|
||||
<View style={styles.detailRow}>
|
||||
<Text
|
||||
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
|
||||
style={[
|
||||
styles.detailLabel,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
Monitors
|
||||
</Text>
|
||||
@@ -299,7 +328,11 @@ export default function IncidentDetailScreen({
|
||||
{ color: theme.colors.textPrimary, flex: 1 },
|
||||
]}
|
||||
>
|
||||
{incident.monitors.map((m) => m.name).join(", ")}
|
||||
{incident.monitors
|
||||
.map((m: NamedEntity) => {
|
||||
return m.name;
|
||||
})
|
||||
.join(", ")}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -310,10 +343,7 @@ export default function IncidentDetailScreen({
|
||||
{!isResolved ? (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
Actions
|
||||
</Text>
|
||||
@@ -324,18 +354,21 @@ export default function IncidentDetailScreen({
|
||||
styles.actionButton,
|
||||
{ backgroundColor: theme.colors.stateAcknowledged },
|
||||
]}
|
||||
onPress={() =>
|
||||
handleStateChange(
|
||||
onPress={() => {
|
||||
return handleStateChange(
|
||||
acknowledgeState._id,
|
||||
acknowledgeState.name,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
disabled={changingState}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Acknowledge incident"
|
||||
>
|
||||
{changingState ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.textInverse} />
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.textInverse}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
@@ -355,15 +388,18 @@ export default function IncidentDetailScreen({
|
||||
styles.actionButton,
|
||||
{ backgroundColor: theme.colors.stateResolved },
|
||||
]}
|
||||
onPress={() =>
|
||||
handleStateChange(resolveState._id, resolveState.name)
|
||||
}
|
||||
onPress={() => {
|
||||
return handleStateChange(resolveState._id, resolveState.name);
|
||||
}}
|
||||
disabled={changingState}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Resolve incident"
|
||||
>
|
||||
{changingState ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.textInverse} />
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.textInverse}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
@@ -384,15 +420,12 @@ export default function IncidentDetailScreen({
|
||||
{timeline && timeline.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
State Timeline
|
||||
</Text>
|
||||
{timeline.map((entry) => {
|
||||
const entryColor = entry.incidentState?.color
|
||||
{timeline.map((entry: StateTimelineItem) => {
|
||||
const entryColor: string = entry.incidentState?.color
|
||||
? rgbToHex(entry.incidentState.color)
|
||||
: theme.colors.textTertiary;
|
||||
return (
|
||||
@@ -449,7 +482,9 @@ export default function IncidentDetailScreen({
|
||||
styles.addNoteButton,
|
||||
{ backgroundColor: theme.colors.actionPrimary },
|
||||
]}
|
||||
onPress={() => setNoteModalVisible(true)}
|
||||
onPress={() => {
|
||||
return setNoteModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
@@ -463,47 +498,49 @@ export default function IncidentDetailScreen({
|
||||
</View>
|
||||
|
||||
{notes && notes.length > 0
|
||||
? notes.map((note) => (
|
||||
<View
|
||||
key={note._id}
|
||||
style={[
|
||||
styles.noteCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
? notes.map((note: NoteItem) => {
|
||||
return (
|
||||
<View
|
||||
key={note._id}
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textPrimary },
|
||||
styles.noteCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{note.note}
|
||||
</Text>
|
||||
<View style={styles.noteMeta}>
|
||||
{note.createdByUser ? (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{note.note}
|
||||
</Text>
|
||||
<View style={styles.noteMeta}>
|
||||
{note.createdByUser ? (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{note.createdByUser.name}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{note.createdByUser.name}
|
||||
{formatDateTime(note.createdAt)}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{formatDateTime(note.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
);
|
||||
})
|
||||
: null}
|
||||
|
||||
{notes && notes.length === 0 ? (
|
||||
@@ -520,7 +557,9 @@ export default function IncidentDetailScreen({
|
||||
|
||||
<AddNoteModal
|
||||
visible={noteModalVisible}
|
||||
onClose={() => setNoteModalVisible(false)}
|
||||
onClose={() => {
|
||||
return setNoteModalVisible(false);
|
||||
}}
|
||||
onSubmit={handleAddNote}
|
||||
isSubmitting={submittingNote}
|
||||
/>
|
||||
@@ -528,7 +567,7 @@ export default function IncidentDetailScreen({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -50,14 +50,12 @@ export default function IncidentEpisodeDetailScreen({
|
||||
refetch: refetchEpisode,
|
||||
} = useIncidentEpisodeDetail(projectId, episodeId);
|
||||
const { data: states } = useIncidentEpisodeStates(projectId);
|
||||
const {
|
||||
data: timeline,
|
||||
refetch: refetchTimeline,
|
||||
} = useIncidentEpisodeStateTimeline(projectId, episodeId);
|
||||
const {
|
||||
data: notes,
|
||||
refetch: refetchNotes,
|
||||
} = useIncidentEpisodeNotes(projectId, episodeId);
|
||||
const { data: timeline, refetch: refetchTimeline } =
|
||||
useIncidentEpisodeStateTimeline(projectId, episodeId);
|
||||
const { data: notes, refetch: refetchNotes } = useIncidentEpisodeNotes(
|
||||
projectId,
|
||||
episodeId,
|
||||
);
|
||||
|
||||
const { successFeedback, errorFeedback } = useHaptics();
|
||||
const [changingState, setChangingState] = useState(false);
|
||||
@@ -75,7 +73,9 @@ export default function IncidentEpisodeDetailScreen({
|
||||
}
|
||||
const queryKey = ["incident-episode", projectId, episodeId];
|
||||
const previousData = queryClient.getQueryData(queryKey);
|
||||
const newState = states?.find((s) => s._id === stateId);
|
||||
const newState = states?.find((s) => {
|
||||
return s._id === stateId;
|
||||
});
|
||||
if (newState) {
|
||||
queryClient.setQueryData(queryKey, {
|
||||
...episode,
|
||||
@@ -132,9 +132,7 @@ export default function IncidentEpisodeDetailScreen({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{ flex: 1, backgroundColor: theme.colors.backgroundPrimary },
|
||||
]}
|
||||
style={[{ flex: 1, backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<SkeletonCard variant="detail" />
|
||||
</View>
|
||||
@@ -169,8 +167,12 @@ export default function IncidentEpisodeDetailScreen({
|
||||
? rgbToHex(episode.incidentSeverity.color)
|
||||
: theme.colors.textTertiary;
|
||||
|
||||
const acknowledgeState = states?.find((s) => s.isAcknowledgedState);
|
||||
const resolveState = states?.find((s) => s.isResolvedState);
|
||||
const acknowledgeState = states?.find((s) => {
|
||||
return s.isAcknowledgedState;
|
||||
});
|
||||
const resolveState = states?.find((s) => {
|
||||
return s.isResolvedState;
|
||||
});
|
||||
|
||||
const currentStateId = episode.currentIncidentState?._id;
|
||||
const isResolved = resolveState?._id === currentStateId;
|
||||
@@ -231,10 +233,7 @@ export default function IncidentEpisodeDetailScreen({
|
||||
{episode.description ? (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
Description
|
||||
</Text>
|
||||
@@ -289,18 +288,12 @@ export default function IncidentEpisodeDetailScreen({
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.detailLabel,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
|
||||
>
|
||||
Created
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.detailValue,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
style={[styles.detailValue, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{formatDateTime(episode.createdAt)}
|
||||
</Text>
|
||||
@@ -308,18 +301,12 @@ export default function IncidentEpisodeDetailScreen({
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.detailLabel,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
|
||||
>
|
||||
Incidents
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.detailValue,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
style={[styles.detailValue, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
{episode.incidentCount ?? 0}
|
||||
</Text>
|
||||
@@ -331,10 +318,7 @@ export default function IncidentEpisodeDetailScreen({
|
||||
{!isResolved ? (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
Actions
|
||||
</Text>
|
||||
@@ -345,12 +329,12 @@ export default function IncidentEpisodeDetailScreen({
|
||||
styles.actionButton,
|
||||
{ backgroundColor: theme.colors.stateAcknowledged },
|
||||
]}
|
||||
onPress={() =>
|
||||
handleStateChange(
|
||||
onPress={() => {
|
||||
return handleStateChange(
|
||||
acknowledgeState._id,
|
||||
acknowledgeState.name,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
disabled={changingState}
|
||||
>
|
||||
{changingState ? (
|
||||
@@ -377,9 +361,9 @@ export default function IncidentEpisodeDetailScreen({
|
||||
styles.actionButton,
|
||||
{ backgroundColor: theme.colors.stateResolved },
|
||||
]}
|
||||
onPress={() =>
|
||||
handleStateChange(resolveState._id, resolveState.name)
|
||||
}
|
||||
onPress={() => {
|
||||
return handleStateChange(resolveState._id, resolveState.name);
|
||||
}}
|
||||
disabled={changingState}
|
||||
>
|
||||
{changingState ? (
|
||||
@@ -407,10 +391,7 @@ export default function IncidentEpisodeDetailScreen({
|
||||
{timeline && timeline.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
State Timeline
|
||||
</Text>
|
||||
@@ -430,10 +411,7 @@ export default function IncidentEpisodeDetailScreen({
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.timelineDot,
|
||||
{ backgroundColor: entryColor },
|
||||
]}
|
||||
style={[styles.timelineDot, { backgroundColor: entryColor }]}
|
||||
/>
|
||||
<View style={styles.timelineInfo}>
|
||||
<Text
|
||||
@@ -478,7 +456,9 @@ export default function IncidentEpisodeDetailScreen({
|
||||
styles.addNoteButton,
|
||||
{ backgroundColor: theme.colors.actionPrimary },
|
||||
]}
|
||||
onPress={() => setNoteModalVisible(true)}
|
||||
onPress={() => {
|
||||
return setNoteModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
@@ -492,47 +472,49 @@ export default function IncidentEpisodeDetailScreen({
|
||||
</View>
|
||||
|
||||
{notes && notes.length > 0
|
||||
? notes.map((note) => (
|
||||
<View
|
||||
key={note._id}
|
||||
style={[
|
||||
styles.noteCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
? notes.map((note) => {
|
||||
return (
|
||||
<View
|
||||
key={note._id}
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textPrimary },
|
||||
styles.noteCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{note.note}
|
||||
</Text>
|
||||
<View style={styles.noteMeta}>
|
||||
{note.createdByUser ? (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{note.note}
|
||||
</Text>
|
||||
<View style={styles.noteMeta}>
|
||||
{note.createdByUser ? (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{note.createdByUser.name}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{note.createdByUser.name}
|
||||
{formatDateTime(note.createdAt)}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{formatDateTime(note.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
);
|
||||
})
|
||||
: null}
|
||||
|
||||
{notes && notes.length === 0 ? (
|
||||
@@ -549,7 +531,9 @@ export default function IncidentEpisodeDetailScreen({
|
||||
|
||||
<AddNoteModal
|
||||
visible={noteModalVisible}
|
||||
onClose={() => setNoteModalVisible(false)}
|
||||
onClose={() => {
|
||||
return setNoteModalVisible(false);
|
||||
}}
|
||||
onSubmit={handleAddNote}
|
||||
isSubmitting={submittingNote}
|
||||
/>
|
||||
|
||||
@@ -54,7 +54,9 @@ export default function IncidentEpisodesScreen(): React.JSX.Element {
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (hasMore && !isLoading) {
|
||||
setPage((prev) => prev + 1);
|
||||
setPage((prev) => {
|
||||
return prev + 1;
|
||||
});
|
||||
}
|
||||
}, [hasMore, isLoading]);
|
||||
|
||||
@@ -105,7 +107,9 @@ export default function IncidentEpisodesScreen(): React.JSX.Element {
|
||||
styles.retryButton,
|
||||
{ backgroundColor: theme.colors.actionPrimary },
|
||||
]}
|
||||
onPress={() => refetch()}
|
||||
onPress={() => {
|
||||
return refetch();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
@@ -129,17 +133,23 @@ export default function IncidentEpisodesScreen(): React.JSX.Element {
|
||||
>
|
||||
<FlatList
|
||||
data={episodes}
|
||||
keyExtractor={(item) => item._id}
|
||||
keyExtractor={(item) => {
|
||||
return item._id;
|
||||
}}
|
||||
contentContainerStyle={
|
||||
episodes.length === 0 ? styles.emptyContainer : styles.list
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<EpisodeCard
|
||||
episode={item}
|
||||
type="incident"
|
||||
onPress={() => handlePress(item)}
|
||||
/>
|
||||
)}
|
||||
renderItem={({ item }) => {
|
||||
return (
|
||||
<EpisodeCard
|
||||
episode={item}
|
||||
type="incident"
|
||||
onPress={() => {
|
||||
return handlePress(item);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No incident episodes"
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "react-native";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { useTheme } from "../theme";
|
||||
import { useTheme, type Theme } from "../theme";
|
||||
import { useProject } from "../hooks/useProject";
|
||||
import { useIncidents } from "../hooks/useIncidents";
|
||||
import { useIncidentStates } from "../hooks/useIncidentDetail";
|
||||
@@ -20,21 +20,27 @@ import SwipeableCard from "../components/SwipeableCard";
|
||||
import SkeletonCard from "../components/SkeletonCard";
|
||||
import EmptyState from "../components/EmptyState";
|
||||
import type { IncidentsStackParamList } from "../navigation/types";
|
||||
import type { IncidentItem } from "../api/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { IncidentItem, IncidentState } from "../api/types";
|
||||
import { QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PAGE_SIZE: number = 20;
|
||||
|
||||
type NavProp = NativeStackNavigationProp<IncidentsStackParamList, "IncidentsList">;
|
||||
type NavProp = NativeStackNavigationProp<
|
||||
IncidentsStackParamList,
|
||||
"IncidentsList"
|
||||
>;
|
||||
|
||||
export default function IncidentsScreen(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const { theme }: { theme: Theme } = useTheme();
|
||||
const { selectedProject } = useProject();
|
||||
const projectId = selectedProject?._id ?? "";
|
||||
const navigation = useNavigation<NavProp>();
|
||||
const projectId: string = selectedProject?._id ?? "";
|
||||
const navigation: NativeStackNavigationProp<
|
||||
IncidentsStackParamList,
|
||||
"IncidentsList"
|
||||
> = useNavigation<NavProp>();
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const skip = page * PAGE_SIZE;
|
||||
const skip: number = page * PAGE_SIZE;
|
||||
|
||||
const { data, isLoading, isError, refetch } = useIncidents(
|
||||
projectId,
|
||||
@@ -43,49 +49,67 @@ export default function IncidentsScreen(): React.JSX.Element {
|
||||
);
|
||||
const { data: states } = useIncidentStates(projectId);
|
||||
const { successFeedback, errorFeedback, lightImpact } = useHaptics();
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient: QueryClient = useQueryClient();
|
||||
|
||||
const acknowledgeState = states?.find((s) => s.isAcknowledgedState);
|
||||
const acknowledgeState: IncidentState | undefined = states?.find(
|
||||
(s: IncidentState) => {
|
||||
return s.isAcknowledgedState;
|
||||
},
|
||||
);
|
||||
|
||||
const incidents = data?.data ?? [];
|
||||
const totalCount = data?.count ?? 0;
|
||||
const hasMore = skip + PAGE_SIZE < totalCount;
|
||||
const incidents: IncidentItem[] = data?.data ?? [];
|
||||
const totalCount: number = data?.count ?? 0;
|
||||
const hasMore: boolean = skip + PAGE_SIZE < totalCount;
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
const onRefresh: () => Promise<void> = useCallback(async () => {
|
||||
lightImpact();
|
||||
setPage(0);
|
||||
await refetch();
|
||||
}, [refetch, lightImpact]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
const loadMore: () => void = useCallback(() => {
|
||||
if (hasMore && !isLoading) {
|
||||
setPage((prev) => prev + 1);
|
||||
setPage((prev: number) => {
|
||||
return prev + 1;
|
||||
});
|
||||
}
|
||||
}, [hasMore, isLoading]);
|
||||
|
||||
const handlePress = useCallback(
|
||||
const handlePress: (incident: IncidentItem) => void = useCallback(
|
||||
(incident: IncidentItem) => {
|
||||
navigation.navigate("IncidentDetail", { incidentId: incident._id });
|
||||
},
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const handleAcknowledge = useCallback(
|
||||
async (incident: IncidentItem) => {
|
||||
if (!acknowledgeState) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await changeIncidentState(projectId, incident._id, acknowledgeState._id);
|
||||
await successFeedback();
|
||||
await refetch();
|
||||
await queryClient.invalidateQueries({ queryKey: ["incidents"] });
|
||||
} catch {
|
||||
await errorFeedback();
|
||||
}
|
||||
},
|
||||
[projectId, acknowledgeState, successFeedback, errorFeedback, refetch, queryClient],
|
||||
);
|
||||
const handleAcknowledge: (incident: IncidentItem) => Promise<void> =
|
||||
useCallback(
|
||||
async (incident: IncidentItem) => {
|
||||
if (!acknowledgeState) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await changeIncidentState(
|
||||
projectId,
|
||||
incident._id,
|
||||
acknowledgeState._id,
|
||||
);
|
||||
await successFeedback();
|
||||
await refetch();
|
||||
await queryClient.invalidateQueries({ queryKey: ["incidents"] });
|
||||
} catch {
|
||||
await errorFeedback();
|
||||
}
|
||||
},
|
||||
[
|
||||
projectId,
|
||||
acknowledgeState,
|
||||
successFeedback,
|
||||
errorFeedback,
|
||||
refetch,
|
||||
queryClient,
|
||||
],
|
||||
);
|
||||
|
||||
if (isLoading && incidents.length === 0) {
|
||||
return (
|
||||
@@ -125,7 +149,9 @@ export default function IncidentsScreen(): React.JSX.Element {
|
||||
styles.retryButton,
|
||||
{ backgroundColor: theme.colors.actionPrimary },
|
||||
]}
|
||||
onPress={() => refetch()}
|
||||
onPress={() => {
|
||||
return refetch();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
@@ -149,26 +175,37 @@ export default function IncidentsScreen(): React.JSX.Element {
|
||||
>
|
||||
<FlatList
|
||||
data={incidents}
|
||||
keyExtractor={(item) => item._id}
|
||||
keyExtractor={(item: IncidentItem) => {
|
||||
return item._id;
|
||||
}}
|
||||
contentContainerStyle={
|
||||
incidents.length === 0 ? styles.emptyContainer : styles.list
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<SwipeableCard
|
||||
rightAction={
|
||||
acknowledgeState &&
|
||||
item.currentIncidentState?._id !== acknowledgeState._id
|
||||
? {
|
||||
label: "Acknowledge",
|
||||
color: "#2EA043",
|
||||
onAction: () => handleAcknowledge(item),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<IncidentCard incident={item} onPress={() => handlePress(item)} />
|
||||
</SwipeableCard>
|
||||
)}
|
||||
renderItem={({ item }: { item: IncidentItem }) => {
|
||||
return (
|
||||
<SwipeableCard
|
||||
rightAction={
|
||||
acknowledgeState &&
|
||||
item.currentIncidentState?._id !== acknowledgeState._id
|
||||
? {
|
||||
label: "Acknowledge",
|
||||
color: "#2EA043",
|
||||
onAction: () => {
|
||||
return handleAcknowledge(item);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<IncidentCard
|
||||
incident={item}
|
||||
onPress={() => {
|
||||
return handlePress(item);
|
||||
}}
|
||||
/>
|
||||
</SwipeableCard>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="No active incidents"
|
||||
@@ -186,7 +223,7 @@ export default function IncidentsScreen(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
Switch,
|
||||
StyleSheet,
|
||||
} from "react-native";
|
||||
import { View, Text, ScrollView, Switch, StyleSheet, ViewStyle, TextStyle } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
import { useHaptics } from "../hooks/useHaptics";
|
||||
import {
|
||||
@@ -43,9 +37,7 @@ function PrefRow({
|
||||
accessibilityLabel={`${label}. ${description}`}
|
||||
>
|
||||
<View style={styles.rowText}>
|
||||
<Text
|
||||
style={[styles.rowLabel, { color: theme.colors.textPrimary }]}
|
||||
>
|
||||
<Text style={[styles.rowLabel, { color: theme.colors.textPrimary }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
@@ -80,16 +72,16 @@ export default function NotificationPreferencesScreen(): React.JSX.Element {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getNotificationPreferences().then((p) => {
|
||||
getNotificationPreferences().then((p: NotificationPreferences) => {
|
||||
setPrefs(p);
|
||||
setLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updatePref = useCallback(
|
||||
const updatePref: (key: keyof NotificationPreferences, value: boolean) => void = useCallback(
|
||||
(key: keyof NotificationPreferences, value: boolean) => {
|
||||
selectionFeedback();
|
||||
const updated = { ...prefs, [key]: value };
|
||||
const updated: NotificationPreferences = { ...prefs, [key]: value };
|
||||
setPrefs(updated);
|
||||
setNotificationPreferences(updated);
|
||||
},
|
||||
@@ -130,25 +122,33 @@ export default function NotificationPreferencesScreen(): React.JSX.Element {
|
||||
label="Incidents"
|
||||
description="New incidents and state changes"
|
||||
value={prefs.incidents}
|
||||
onValueChange={(v) => updatePref("incidents", v)}
|
||||
onValueChange={(v: boolean) => {
|
||||
return updatePref("incidents", v);
|
||||
}}
|
||||
/>
|
||||
<PrefRow
|
||||
label="Alerts"
|
||||
description="New alerts and state changes"
|
||||
value={prefs.alerts}
|
||||
onValueChange={(v) => updatePref("alerts", v)}
|
||||
onValueChange={(v: boolean) => {
|
||||
return updatePref("alerts", v);
|
||||
}}
|
||||
/>
|
||||
<PrefRow
|
||||
label="Incident Episodes"
|
||||
description="Grouped incident notifications"
|
||||
value={prefs.incidentEpisodes}
|
||||
onValueChange={(v) => updatePref("incidentEpisodes", v)}
|
||||
onValueChange={(v: boolean) => {
|
||||
return updatePref("incidentEpisodes", v);
|
||||
}}
|
||||
/>
|
||||
<PrefRow
|
||||
label="Alert Episodes"
|
||||
description="Grouped alert notifications"
|
||||
value={prefs.alertEpisodes}
|
||||
onValueChange={(v) => updatePref("alertEpisodes", v)}
|
||||
onValueChange={(v: boolean) => {
|
||||
return updatePref("alertEpisodes", v);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -166,25 +166,39 @@ export default function NotificationPreferencesScreen(): React.JSX.Element {
|
||||
label="Critical Only"
|
||||
description="Only receive notifications for critical and high severity events"
|
||||
value={prefs.criticalOnly}
|
||||
onValueChange={(v) => updatePref("criticalOnly", v)}
|
||||
onValueChange={(v: boolean) => {
|
||||
return updatePref("criticalOnly", v);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.infoSection}>
|
||||
<Text
|
||||
style={[styles.infoText, { color: theme.colors.textTertiary }]}
|
||||
>
|
||||
Notification preferences are stored locally on this device. Server-side
|
||||
notification rules configured in your project settings take precedence.
|
||||
<Text style={[styles.infoText, { color: theme.colors.textTertiary }]}>
|
||||
Notification preferences are stored locally on this device.
|
||||
Server-side notification rules configured in your project settings
|
||||
take precedence.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: {
|
||||
container: ViewStyle;
|
||||
content: ViewStyle;
|
||||
section: ViewStyle;
|
||||
sectionTitle: TextStyle;
|
||||
sectionHint: TextStyle;
|
||||
rowGroup: ViewStyle;
|
||||
row: ViewStyle;
|
||||
rowText: ViewStyle;
|
||||
rowLabel: TextStyle;
|
||||
rowDescription: TextStyle;
|
||||
infoSection: ViewStyle;
|
||||
infoText: TextStyle;
|
||||
} = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -98,7 +98,10 @@ export default function ProjectSelectionScreen(): React.JSX.Element {
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text
|
||||
style={[theme.typography.titleLarge, { color: theme.colors.textPrimary }]}
|
||||
style={[
|
||||
theme.typography.titleLarge,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
Select Project
|
||||
</Text>
|
||||
@@ -114,48 +117,54 @@ export default function ProjectSelectionScreen(): React.JSX.Element {
|
||||
|
||||
<FlatList
|
||||
data={projectList}
|
||||
keyExtractor={(item) => item._id}
|
||||
keyExtractor={(item) => {
|
||||
return item._id;
|
||||
}}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.projectCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
},
|
||||
]}
|
||||
onPress={() => handleSelect(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View
|
||||
renderItem={({ item }) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.projectDot,
|
||||
{ backgroundColor: theme.colors.actionPrimary },
|
||||
styles.projectCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View style={styles.projectInfo}>
|
||||
<Text
|
||||
onPress={() => {
|
||||
return handleSelect(item);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
theme.typography.bodyLarge,
|
||||
{ color: theme.colors.textPrimary, fontWeight: "600" },
|
||||
styles.projectDot,
|
||||
{ backgroundColor: theme.colors.actionPrimary },
|
||||
]}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.slug ? (
|
||||
/>
|
||||
<View style={styles.projectInfo}>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
theme.typography.bodyLarge,
|
||||
{ color: theme.colors.textPrimary, fontWeight: "600" },
|
||||
]}
|
||||
>
|
||||
{item.slug}
|
||||
{item.name}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{item.slug ? (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textTertiary },
|
||||
]}
|
||||
>
|
||||
{item.slug}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,10 @@ import { useHaptics } from "../hooks/useHaptics";
|
||||
import { getServerUrl } from "../storage/serverUrl";
|
||||
import type { SettingsStackParamList } from "../navigation/types";
|
||||
|
||||
type SettingsNavProp = NativeStackNavigationProp<SettingsStackParamList, "SettingsList">;
|
||||
type SettingsNavProp = NativeStackNavigationProp<
|
||||
SettingsStackParamList,
|
||||
"SettingsList"
|
||||
>;
|
||||
|
||||
const APP_VERSION = "1.0.0";
|
||||
|
||||
@@ -64,7 +67,9 @@ function SettingsRow({
|
||||
</Text>
|
||||
{rightElement ??
|
||||
(value ? (
|
||||
<Text style={[styles.rowValue, { color: theme.colors.textSecondary }]}>
|
||||
<Text
|
||||
style={[styles.rowValue, { color: theme.colors.textSecondary }]}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
) : onPress ? (
|
||||
@@ -147,16 +152,16 @@ export default function SettingsScreen(): React.JSX.Element {
|
||||
backgroundColor: theme.colors.actionPrimary,
|
||||
},
|
||||
]}
|
||||
onPress={() => handleThemeChange(mode)}
|
||||
onPress={() => {
|
||||
return handleThemeChange(mode);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.themeOptionIcon,
|
||||
{
|
||||
color: isActive
|
||||
? "#FFFFFF"
|
||||
: theme.colors.textSecondary,
|
||||
color: isActive ? "#FFFFFF" : theme.colors.textSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -166,9 +171,7 @@ export default function SettingsScreen(): React.JSX.Element {
|
||||
style={[
|
||||
styles.themeOptionLabel,
|
||||
{
|
||||
color: isActive
|
||||
? "#FFFFFF"
|
||||
: theme.colors.textPrimary,
|
||||
color: isActive ? "#FFFFFF" : theme.colors.textPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -189,7 +192,9 @@ export default function SettingsScreen(): React.JSX.Element {
|
||||
</Text>
|
||||
<SettingsRow
|
||||
label="Notification Preferences"
|
||||
onPress={() => navigation.navigate("NotificationPreferences")}
|
||||
onPress={() => {
|
||||
return navigation.navigate("NotificationPreferences");
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -245,10 +250,7 @@ export default function SettingsScreen(): React.JSX.Element {
|
||||
>
|
||||
Server
|
||||
</Text>
|
||||
<SettingsRow
|
||||
label="Server URL"
|
||||
value={serverUrl || "oneuptime.com"}
|
||||
/>
|
||||
<SettingsRow label="Server URL" value={serverUrl || "oneuptime.com"} />
|
||||
</View>
|
||||
|
||||
{/* Account */}
|
||||
@@ -258,11 +260,7 @@ export default function SettingsScreen(): React.JSX.Element {
|
||||
>
|
||||
Account
|
||||
</Text>
|
||||
<SettingsRow
|
||||
label="Log Out"
|
||||
onPress={logout}
|
||||
destructive
|
||||
/>
|
||||
<SettingsRow label="Log Out" onPress={logout} destructive />
|
||||
</View>
|
||||
|
||||
{/* About */}
|
||||
@@ -279,9 +277,7 @@ export default function SettingsScreen(): React.JSX.Element {
|
||||
|
||||
{/* Footer branding */}
|
||||
<View style={styles.footer}>
|
||||
<Text
|
||||
style={[styles.footerText, { color: theme.colors.textTertiary }]}
|
||||
>
|
||||
<Text style={[styles.footerText, { color: theme.colors.textTertiary }]}>
|
||||
OneUptime On-Call
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
} from "react-native";
|
||||
import { useTheme } from "../../theme";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
import { LoginResponse } from "../../api/auth";
|
||||
import { getServerUrl } from "../../storage/serverUrl";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
@@ -25,7 +28,7 @@ type LoginNavigationProp = NativeStackNavigationProp<
|
||||
export default function LoginScreen(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const { login, setNeedsServerUrl } = useAuth();
|
||||
const navigation = useNavigation<LoginNavigationProp>();
|
||||
const navigation: LoginNavigationProp = useNavigation<LoginNavigationProp>();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [serverUrl, setServerUrlState] = useState("");
|
||||
@@ -36,7 +39,7 @@ export default function LoginScreen(): React.JSX.Element {
|
||||
getServerUrl().then(setServerUrlState);
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (): Promise<void> => {
|
||||
const handleLogin: () => Promise<void> = async (): Promise<void> => {
|
||||
if (!email.trim() || !password.trim()) {
|
||||
setError("Email and password are required.");
|
||||
return;
|
||||
@@ -46,7 +49,7 @@ export default function LoginScreen(): React.JSX.Element {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await login(email.trim(), password);
|
||||
const response: LoginResponse = await login(email.trim(), password);
|
||||
|
||||
if (response.twoFactorRequired) {
|
||||
setError(
|
||||
@@ -54,7 +57,7 @@ export default function LoginScreen(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
const message: string =
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
"Login failed. Please check your credentials.";
|
||||
@@ -64,7 +67,7 @@ export default function LoginScreen(): React.JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeServer = (): void => {
|
||||
const handleChangeServer: () => void = (): void => {
|
||||
setNeedsServerUrl(true);
|
||||
navigation.navigate("ServerUrl");
|
||||
};
|
||||
@@ -123,7 +126,7 @@ export default function LoginScreen(): React.JSX.Element {
|
||||
},
|
||||
]}
|
||||
value={email}
|
||||
onChangeText={(text) => {
|
||||
onChangeText={(text: string) => {
|
||||
setEmail(text);
|
||||
setError(null);
|
||||
}}
|
||||
@@ -158,7 +161,7 @@ export default function LoginScreen(): React.JSX.Element {
|
||||
},
|
||||
]}
|
||||
value={password}
|
||||
onChangeText={(text) => {
|
||||
onChangeText={(text: string) => {
|
||||
setPassword(text);
|
||||
setError(null);
|
||||
}}
|
||||
@@ -229,7 +232,16 @@ export default function LoginScreen(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: {
|
||||
flex: ViewStyle;
|
||||
scrollContent: ViewStyle;
|
||||
container: ViewStyle;
|
||||
header: ViewStyle;
|
||||
form: ViewStyle;
|
||||
input: TextStyle;
|
||||
button: ViewStyle;
|
||||
changeServer: ViewStyle;
|
||||
} = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
} from "react-native";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
@@ -26,12 +28,12 @@ type ServerUrlNavigationProp = NativeStackNavigationProp<
|
||||
export default function ServerUrlScreen(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const { setNeedsServerUrl } = useAuth();
|
||||
const navigation = useNavigation<ServerUrlNavigationProp>();
|
||||
const navigation: ServerUrlNavigationProp = useNavigation<ServerUrlNavigationProp>();
|
||||
const [url, setUrl] = useState("https://oneuptime.com");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleConnect = async (): Promise<void> => {
|
||||
const handleConnect: () => Promise<void> = async (): Promise<void> => {
|
||||
if (!url.trim()) {
|
||||
setError("Please enter a server URL");
|
||||
return;
|
||||
@@ -41,8 +43,8 @@ export default function ServerUrlScreen(): React.JSX.Element {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const normalizedUrl = url.trim().replace(/\/+$/, "");
|
||||
const isValid = await validateServerUrl(normalizedUrl);
|
||||
const normalizedUrl: string = url.trim().replace(/\/+$/, "");
|
||||
const isValid: boolean = await validateServerUrl(normalizedUrl);
|
||||
|
||||
if (!isValid) {
|
||||
setError(
|
||||
@@ -118,7 +120,7 @@ export default function ServerUrlScreen(): React.JSX.Element {
|
||||
},
|
||||
]}
|
||||
value={url}
|
||||
onChangeText={(text) => {
|
||||
onChangeText={(text: string) => {
|
||||
setUrl(text);
|
||||
setError(null);
|
||||
}}
|
||||
@@ -189,7 +191,15 @@ export default function ServerUrlScreen(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const styles: {
|
||||
flex: ViewStyle;
|
||||
scrollContent: ViewStyle;
|
||||
container: ViewStyle;
|
||||
header: ViewStyle;
|
||||
form: ViewStyle;
|
||||
input: TextStyle;
|
||||
button: ViewStyle;
|
||||
} = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Keychain from "react-native-keychain";
|
||||
|
||||
const SERVICE_NAME = "com.oneuptime.oncall.tokens";
|
||||
const SERVICE_NAME: string = "com.oneuptime.oncall.tokens";
|
||||
|
||||
export interface StoredTokens {
|
||||
accessToken: string;
|
||||
@@ -17,15 +17,13 @@ export function getCachedAccessToken(): string | null {
|
||||
|
||||
export async function storeTokens(tokens: StoredTokens): Promise<void> {
|
||||
cachedAccessToken = tokens.accessToken;
|
||||
await Keychain.setGenericPassword(
|
||||
"tokens",
|
||||
JSON.stringify(tokens),
|
||||
{ service: SERVICE_NAME },
|
||||
);
|
||||
await Keychain.setGenericPassword("tokens", JSON.stringify(tokens), {
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTokens(): Promise<StoredTokens | null> {
|
||||
const credentials = await Keychain.getGenericPassword({
|
||||
const credentials: false | Keychain.UserCredentials = await Keychain.getGenericPassword({
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { ThemeMode } from "../theme";
|
||||
|
||||
const KEYS = {
|
||||
const KEYS: {
|
||||
readonly THEME_MODE: "oneuptime_theme_mode";
|
||||
readonly BIOMETRIC_ENABLED: "oneuptime_biometric_enabled";
|
||||
readonly NOTIFICATION_PREFS: "oneuptime_notification_prefs";
|
||||
} = {
|
||||
THEME_MODE: "oneuptime_theme_mode",
|
||||
BIOMETRIC_ENABLED: "oneuptime_biometric_enabled",
|
||||
NOTIFICATION_PREFS: "oneuptime_notification_prefs",
|
||||
@@ -24,7 +28,7 @@ const DEFAULT_NOTIFICATION_PREFS: NotificationPreferences = {
|
||||
};
|
||||
|
||||
export async function getThemeMode(): Promise<ThemeMode> {
|
||||
const stored = await AsyncStorage.getItem(KEYS.THEME_MODE);
|
||||
const stored: string | null = await AsyncStorage.getItem(KEYS.THEME_MODE);
|
||||
if (stored === "dark" || stored === "light" || stored === "system") {
|
||||
return stored;
|
||||
}
|
||||
@@ -36,7 +40,7 @@ export async function setThemeMode(mode: ThemeMode): Promise<void> {
|
||||
}
|
||||
|
||||
export async function getBiometricEnabled(): Promise<boolean> {
|
||||
const stored = await AsyncStorage.getItem(KEYS.BIOMETRIC_ENABLED);
|
||||
const stored: string | null = await AsyncStorage.getItem(KEYS.BIOMETRIC_ENABLED);
|
||||
return stored === "true";
|
||||
}
|
||||
|
||||
@@ -45,7 +49,7 @@ export async function setBiometricEnabled(enabled: boolean): Promise<void> {
|
||||
}
|
||||
|
||||
export async function getNotificationPreferences(): Promise<NotificationPreferences> {
|
||||
const stored = await AsyncStorage.getItem(KEYS.NOTIFICATION_PREFS);
|
||||
const stored: string | null = await AsyncStorage.getItem(KEYS.NOTIFICATION_PREFS);
|
||||
if (stored) {
|
||||
try {
|
||||
return { ...DEFAULT_NOTIFICATION_PREFS, ...JSON.parse(stored) };
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
const STORAGE_KEY = "oneuptime_server_url";
|
||||
const DEFAULT_SERVER_URL = "https://oneuptime.com";
|
||||
const STORAGE_KEY: string = "oneuptime_server_url";
|
||||
const DEFAULT_SERVER_URL: string = "https://oneuptime.com";
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export async function getServerUrl(): Promise<string> {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
const stored: string | null = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
return stored || DEFAULT_SERVER_URL;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function setServerUrl(url: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function hasServerUrl(): Promise<boolean> {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
const stored: string | null = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
return stored !== null;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,29 +31,31 @@ interface ThemeContextValue {
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
const ThemeContext: React.Context<ThemeContextValue | undefined> = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Element {
|
||||
const systemColorScheme = useColorScheme();
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
}: ThemeProviderProps): React.JSX.Element {
|
||||
const systemColorScheme: "light" | "dark" | null | undefined = useColorScheme();
|
||||
const [themeMode, setThemeModeState] = useState<ThemeMode>("dark");
|
||||
|
||||
// Load persisted theme on mount
|
||||
useEffect(() => {
|
||||
loadThemeMode().then((mode) => {
|
||||
loadThemeMode().then((mode: ThemeMode) => {
|
||||
setThemeModeState(mode);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setThemeMode = (mode: ThemeMode): void => {
|
||||
const setThemeMode: (mode: ThemeMode) => void = (mode: ThemeMode): void => {
|
||||
setThemeModeState(mode);
|
||||
saveThemeMode(mode);
|
||||
};
|
||||
|
||||
const theme = useMemo((): Theme => {
|
||||
const theme: Theme = useMemo((): Theme => {
|
||||
let isDark: boolean;
|
||||
|
||||
if (themeMode === "system") {
|
||||
@@ -71,14 +73,13 @@ export function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Eleme
|
||||
};
|
||||
}, [themeMode, systemColorScheme]);
|
||||
|
||||
const value = useMemo(
|
||||
(): ThemeContextValue => ({
|
||||
const value: ThemeContextValue = useMemo((): ThemeContextValue => {
|
||||
return {
|
||||
theme,
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
}),
|
||||
[theme, themeMode],
|
||||
);
|
||||
};
|
||||
}, [theme, themeMode]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
@@ -86,7 +87,7 @@ export function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Eleme
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const context = useContext(ThemeContext);
|
||||
const context: ThemeContextValue | undefined = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export const spacing = {
|
||||
export const spacing: {
|
||||
readonly xs: 4;
|
||||
readonly sm: 8;
|
||||
readonly md: 16;
|
||||
readonly lg: 24;
|
||||
readonly xl: 32;
|
||||
} = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
@@ -6,7 +12,11 @@ export const spacing = {
|
||||
xl: 32,
|
||||
} as const;
|
||||
|
||||
export const radius = {
|
||||
export const radius: {
|
||||
readonly sm: 6;
|
||||
readonly md: 12;
|
||||
readonly lg: 16;
|
||||
} = {
|
||||
sm: 6,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
export function rgbToHex(color: {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}): string {
|
||||
const r = Math.max(0, Math.min(255, Math.round(color.r)));
|
||||
const g = Math.max(0, Math.min(255, Math.round(color.g)));
|
||||
const b = Math.max(0, Math.min(255, Math.round(color.b)));
|
||||
export function rgbToHex(color: { r: number; g: number; b: number }): string {
|
||||
const r: number = Math.max(0, Math.min(255, Math.round(color.r)));
|
||||
const g: number = Math.max(0, Math.min(255, Math.round(color.g)));
|
||||
const b: number = Math.max(0, Math.min(255, Math.round(color.b)));
|
||||
|
||||
return (
|
||||
"#" +
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
export function formatRelativeTime(dateString: string): string {
|
||||
const now = Date.now();
|
||||
const date = new Date(dateString).getTime();
|
||||
const seconds = Math.floor((now - date) / 1000);
|
||||
const now: number = Date.now();
|
||||
const date: number = new Date(dateString).getTime();
|
||||
const seconds: number = Math.floor((now - date) / 1000);
|
||||
|
||||
if (seconds < 60) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const minutes: number = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const hours: number = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
const days: number = Math.floor(hours / 24);
|
||||
if (days < 30) {
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
const months = Math.floor(days / 30);
|
||||
const months: number = Math.floor(days / 30);
|
||||
if (months < 12) {
|
||||
return `${months}mo ago`;
|
||||
}
|
||||
|
||||
const years = Math.floor(months / 12);
|
||||
const years: number = Math.floor(months / 12);
|
||||
return `${years}y ago`;
|
||||
}
|
||||
|
||||
export function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const date: Date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
|
||||
Reference in New Issue
Block a user