import type { FeedbackComment, FeedbackEntry as FeedbackEntryData, } from "convex-feedback"; import { createContext, forwardRef, useContext, useMemo, type PropsWithChildren, } from "react"; import { Pressable, ScrollView, Text, TextInput, View, type PressableProps, type ScrollViewProps, type StyleProp, type TextInputProps, type TextProps, type TextStyle, type ViewProps, type ViewStyle, } from "react-native"; import { FeedbackNativeThemeScope, useFeedbackUi, } from "../../../shared/context/FeedbackProvider.js"; import type { BoardSearchBaseProps, BoardSearchState, ChoiceChipsBaseProps, CommentActionBaseProps, CommentLikeBaseProps, CommentLikeState, CommentRootBaseProps, EntryRootBaseProps, EntryUpvoteBaseProps, EntryUpvoteState, FeedbackColorProps, FormSubmitBaseProps, FormSubmitState, RepliesButtonBaseProps, } from "../../../shared/types"; import { combineStyle } from "../helpers.js"; /** * Props for `FeedbackBoard.Root`. */ export type BoardRootProps = PropsWithChildren; function BoardRoot({ primaryColor, primaryForeground, backgroundColor, surfaceColor, inputColor, textColor, mutedColor, borderColor, dangerColor, children, }: BoardRootProps) { const colorOverrides = useMemo( () => ({ ...(primaryColor === undefined ? {} : { primary: primaryColor }), ...(primaryForeground === undefined ? {} : { primaryForeground }), ...(backgroundColor === undefined ? {} : { background: backgroundColor }), ...(surfaceColor === undefined ? {} : { surface: surfaceColor }), ...(inputColor === undefined ? {} : { input: inputColor }), ...(textColor === undefined ? {} : { text: textColor }), ...(mutedColor === undefined ? {} : { mutedText: mutedColor }), ...(borderColor === undefined ? {} : { border: borderColor }), ...(dangerColor === undefined ? {} : { danger: dangerColor }), }), [ backgroundColor, inputColor, borderColor, dangerColor, mutedColor, primaryColor, primaryForeground, surfaceColor, textColor, ], ); return ( {children} ); } function BoardHeader({ style, ...props }: ViewProps) { const { theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { gap: theme.spacing, backgroundColor: theme.colors.background }, style, )} /> ); } function BoardTitle({ style, children, ...props }: TextProps) { const { messages, theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { color: theme.colors.text, fontSize: 24, fontWeight: "700" }, style, )} > {children ?? messages.board.title} ); } /** * Props for native `FeedbackBoard.Search`. */ export interface BoardSearchProps extends Omit, BoardSearchBaseProps {} const BoardSearch = forwardRef( ({ value, onValueChange, children, style, ...props }, ref) => { const { messages, theme, unstyled } = useFeedbackUi(); const state: BoardSearchState = { value, setValue: onValueChange }; if (typeof children === "function") return <>{children(state)}; return ( ( !unstyled, { color: theme.colors.text, borderWidth: 1, borderColor: theme.colors.border, borderRadius: Math.max(8, theme.radius - 2), paddingHorizontal: 12, paddingVertical: 10, backgroundColor: theme.colors.input, }, style, )} /> ); }, ); // change to forwardRef const BoardList = forwardRef( ({ style, ...props }, ref) => { const { theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { gap: Math.max(8, theme.spacing - 2) }, style, )} /> ); }, ); function BoardState({ style, ...props }: ViewProps) { const { theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { flex: 1, alignItems: "center", justifyContent: "center", gap: theme.spacing, padding: theme.spacing, }, style, )} /> ); } export const FeedbackBoard = { Root: BoardRoot, Header: BoardHeader, Title: BoardTitle, Search: BoardSearch, List: BoardList, State: BoardState, }; /** Props for the reusable native `ChoiceChips` primitive. */ export interface ChoiceChipsProps extends Omit, ChoiceChipsBaseProps {} export function ChoiceChips({ options, value, onValueChange, style, ...props }: ChoiceChipsProps) { const { theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { flexDirection: "row", flexWrap: "wrap", gap: 7 }, style, )} > {options.map((option) => { const selected = option.value === value; return ( onValueChange(option.value)} style={ unstyled ? undefined : { paddingVertical: 6, paddingHorizontal: 9, borderRadius: 999, borderWidth: 1, borderColor: selected ? theme.colors.primary : theme.colors.border, backgroundColor: selected ? theme.colors.primary : "transparent", } } > {option.label} ); })} ); } const EntryContext = createContext(null); function useEntryContext(): FeedbackEntryData { const value = useContext(EntryContext); if (value === null) throw new Error( "FeedbackEntry compound must be inside FeedbackEntry.Root.", ); return value; } /** * Props for native `FeedbackEntry.Root`. */ export interface EntryRootProps extends ViewProps, EntryRootBaseProps {} function EntryRoot({ entry, style, ...props }: EntryRootProps) { const { theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { flexDirection: "row", gap: 12, padding: 12, borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.radius, backgroundColor: theme.colors.surface, }, style, )} /> ); } export interface EntryUpvoteProps extends Omit, EntryUpvoteBaseProps { primaryColor?: string; style?: StyleProp; } function EntryUpvote({ onToggle, children, primaryColor, style, accessibilityLabel, ...props }: EntryUpvoteProps) { const entry = useEntryContext(); const { theme, unstyled, messages } = useFeedbackUi(); const activeColor = primaryColor ?? theme.colors.primary; const toggle = () => onToggle(!entry.viewerHasUpvoted); const state: EntryUpvoteState = { entry, active: entry.viewerHasUpvoted, count: entry.upvoteCount, toggle, }; if (typeof children === "function") return <>{children(state)}; return ( ( !unstyled, { width: 48, minHeight: 54, alignItems: "center", justifyContent: "center", borderWidth: 1, borderColor: entry.viewerHasUpvoted ? activeColor : theme.colors.border, borderRadius: 10, backgroundColor: theme.colors.surfaceMuted, }, style, )} > {children ?? ( <> ▲ {entry.upvoteCount} )} ); } function EntryKind({ style, children, ...props }: TextProps) { const entry = useEntryContext(); const { messages, theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { color: theme.colors.mutedText, fontSize: 12, fontWeight: "600", }, style, )} > {children ?? messages.kinds[entry.kind]} ); } function EntryContent({ style, ...props }: ViewProps) { return ; } function EntryTitle({ style, children, ...props }: TextProps) { const entry = useEntryContext(); const { theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { color: theme.colors.text, fontWeight: "700", fontSize: 16 }, style, )} > {children ?? entry.title} ); } function EntryBody({ style, children, ...props }: TextProps) { const entry = useEntryContext(); const { theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { color: theme.colors.mutedText, lineHeight: 20 }, style, )} > {children ?? entry.body} ); } function EntryStatus({ style, children, ...props }: TextProps) { const entry = useEntryContext(); const { messages, theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { color: theme.colors.mutedText, fontSize: 12, fontWeight: "600" }, style, )} > {children ?? messages.statuses[entry.status]} ); } function EntryCommentCount({ style, children, ...props }: TextProps) { const entry = useEntryContext(); const { messages, theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { color: theme.colors.mutedText, fontSize: 12 }, style, )} > {children ?? messages.entry.comments(entry.commentCount)} ); } export const FeedbackEntry = { Root: EntryRoot, Upvote: EntryUpvote, Content: EntryContent, Kind: EntryKind, Title: EntryTitle, Body: EntryBody, Status: EntryStatus, CommentCount: EntryCommentCount, }; const CommentContext = createContext(null); function useCommentContext(): FeedbackComment { const value = useContext(CommentContext); if (value === null) throw new Error("Comment compound must be inside Comment.Root."); return value; } export interface CommentRootProps extends ViewProps, CommentRootBaseProps {} function CommentRoot({ comment, style, ...props }: CommentRootProps) { const { theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { gap: 7, padding: 10, borderWidth: 1, borderColor: theme.colors.border, borderRadius: Math.max(8, theme.radius - 2), backgroundColor: theme.colors.surface, }, style, )} /> ); } function CommentBody({ style, children, ...props }: TextProps) { const comment = useCommentContext(); const { theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { color: theme.colors.text, lineHeight: 20, }, style, )} > {children ?? comment.body} ); } export interface CommentLikeProps extends Omit, CommentLikeBaseProps { primaryColor?: string; style?: StyleProp; } function CommentLike({ onToggle, children, primaryColor, style, accessibilityLabel, ...props }: CommentLikeProps) { const comment = useCommentContext(); const { theme, unstyled, messages } = useFeedbackUi(); const color = primaryColor ?? theme.colors.primary; const toggle = () => onToggle(!comment.viewerHasLiked); const state: CommentLikeState = { comment, active: comment.viewerHasLiked, count: comment.likeCount, toggle, }; if (typeof children === "function") return <>{children(state)}; return ( ( !unstyled, { flexDirection: "row", gap: 4, alignItems: "center", paddingVertical: 4, paddingHorizontal: 5, }, style, )} > {children ?? ( <> {comment.viewerHasLiked ? "♥" : "♡"} {comment.likeCount} )} ); } export interface CommentActionProps extends Omit, CommentActionBaseProps { style?: StyleProp; } function CommentReply({ onActivate, children, style, ...props }: CommentActionProps) { const comment = useCommentContext(); const { messages, theme, unstyled } = useFeedbackUi(); const state = { comment, activate: onActivate }; if (typeof children === "function") return <>{children(state)}; return ( (!unstyled, { padding: 5 }, style)} > {children ?? messages.comments.reply} ); } export interface RepliesButtonProps extends Omit, RepliesButtonBaseProps { style?: StyleProp; } function CommentRepliesButton({ expanded, onExpandedChange, children, style, ...props }: RepliesButtonProps) { const comment = useCommentContext(); const { messages, theme, unstyled } = useFeedbackUi(); const activate = () => onExpandedChange(!expanded); const state = { comment, expanded, count: comment.replyCount, activate }; if (typeof children === "function") return <>{children(state)}; if (comment.replyCount === 0) return null; return ( (!unstyled, { padding: 5 }, style)} > {children ?? (expanded ? messages.comments.hideReplies : messages.comments.viewReplies(comment.replyCount))} ); } function CommentChildren({ style, ...props }: ViewProps) { const { theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { gap: 8, marginLeft: 14, paddingLeft: 10, borderLeftWidth: 2, borderLeftColor: theme.colors.border, }, style, )} /> ); } export const Comment = { Root: CommentRoot, Body: CommentBody, Like: CommentLike, Reply: CommentReply, RepliesButton: CommentRepliesButton, Children: CommentChildren, }; function FormRoot({ style, ...props }: ViewProps) { const { unstyled } = useFeedbackUi(); return ( (!unstyled, { gap: 9 }, style)} /> ); } function FormInput({ style, ...props }: TextInputProps) { const { theme, unstyled } = useFeedbackUi(); return ( ( !unstyled, { color: theme.colors.text, borderWidth: 1, borderColor: theme.colors.border, borderRadius: Math.max(8, theme.radius - 2), paddingHorizontal: 11, paddingVertical: 9, backgroundColor: theme.colors.input, maxHeight: 100, }, style, )} /> ); } function FormTextarea({ style, ...props }: TextInputProps) { return ( ); } export interface FormSubmitProps extends Omit, FormSubmitBaseProps { backgroundColor?: string; color?: string; style?: StyleProp; } function FormSubmit({ submitting = false, children, backgroundColor, color, disabled, style, ...props }: FormSubmitProps) { const { messages, theme, unstyled } = useFeedbackUi(); const state: FormSubmitState = { submitting }; if (typeof children === "function") return <>{children(state)}; return ( ( !unstyled, { minHeight: 42, alignItems: "center", justifyContent: "center", borderRadius: Math.max(8, theme.radius - 2), paddingHorizontal: 12, backgroundColor: backgroundColor ?? theme.colors.primary, opacity: (disabled ?? submitting) ? 0.55 : 1, }, style, )} > {children ?? messages.form.submit} ); } export const FeedbackForm = { Root: FormRoot, Input: FormInput, Textarea: FormTextarea, Submit: FormSubmit, };