diff --git a/package-lock.json b/package-lock.json index 9d90b68..e8da791 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "jsqr": "1.4.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", - "onehook-api-client": "^1.0.33", + "onehook-api-client": "^1.0.35", "qrcode.react": "^4.2.0", "react": "^19.0.1", "react-dom": "^19.0.1", @@ -5704,9 +5704,9 @@ } }, "node_modules/onehook-api-client": { - "version": "1.0.33", - "resolved": "https://registry.npmjs.org/onehook-api-client/-/onehook-api-client-1.0.33.tgz", - "integrity": "sha512-mtGm/lOW5GpDHkArNfdKGckv/CR1azKQm8ImIX63hHI3yv088rB3vXRmCObqdOKSvp2j+QdkbWmaYBbnl8MLPQ==", + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/onehook-api-client/-/onehook-api-client-1.0.35.tgz", + "integrity": "sha512-/Bdi78ttoCUtjrMvS4vB2jIGPGIEmrAT0GEPNmcLL9w2V9dvUQYtLzWf6KGweUs8rudxKL0TeTsBokZET6LZBw==", "license": "UNLICENSED", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", diff --git a/package.json b/package.json index 3236c7a..a783c80 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "jsqr": "1.4.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", - "onehook-api-client": "^1.0.33", + "onehook-api-client": "^1.0.35", "qrcode.react": "^4.2.0", "react": "^19.0.1", "react-dom": "^19.0.1", diff --git a/src/api/rest.ts b/src/api/rest.ts index 49c8e37..1847ab2 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -172,8 +172,26 @@ export const BlocksApi = { * replaces it, and only the member removes it. The current one comes with the preferences * (`currentSuggestion`). */ +export interface SuggestionView { + suggestionId: string; + reasoning: string; + createdAt: string; +} + +export interface MemberSuggestions { + /** Written by the member ("who I'm looking for"). */ + own?: SuggestionView; + /** Recorded by Mr. OneHook from a "find me ..." chat. */ + oneHook?: SuggestionView; +} + +/** The member's two asked-for suggestions (Super and above); each removable on its own. */ export const SuggestionApi = { - remove: (): Promise => request('DELETE', '/matching/suggestions'), + get: (): Promise => request('GET', '/matching/suggestions'), + saveOwn: (prompt: string, reasoning: string): Promise<{ suggestionId: string }> => + request('POST', '/matching/suggestions', { prompt, reasoning }), + removeOwn: (): Promise => request('DELETE', '/matching/suggestions'), + removeOneHook: (): Promise => request('DELETE', '/matching/suggestions/onehook'), }; // ── Chat settings ───────────────────────────────────────────────────────────── diff --git a/src/features/chat/ChatView.tsx b/src/features/chat/ChatView.tsx index 4e943d3..cb94469 100644 --- a/src/features/chat/ChatView.tsx +++ b/src/features/chat/ChatView.tsx @@ -65,13 +65,14 @@ import { type MrOneHookMessage, type MrOneHookScope, type PendingExcerptReview, + type SessionListItem, } from '../../lib/mr-onehook'; import { selectEphemeralExcerpts, toSelectableMessages, type ExcerptSelectionRequest, } from '../../lib/mr-onehook-excerpts'; -import { DEFAULT_SUBSCRIPTION_TIER } from 'onehook-api-client/membership'; +import { DEFAULT_SUBSCRIPTION_TIER, isPaidSubscriptionTier } from 'onehook-api-client/membership'; import type { SubscriptionTier } from '../../types'; import { ChatSettingsSheet } from '../../components/chat/ChatSettingsSheet'; import { AttachmentBubble } from '../../components/chat/AttachmentBubble'; @@ -1572,7 +1573,7 @@ function MrOneHookProductScope({ scope: { type: 'product' }, tier, }); - return ; + return ; } /** @@ -1613,7 +1614,9 @@ function MrOneHookMatchScope({ resolveLocalExcerpts, }); - return ; + return ( + + ); } /** @@ -1625,11 +1628,17 @@ function MrOneHookChatBody({ chat, wallpaper, matchName, + tier, + scopeType, }: { chat: ReturnType; wallpaper: ChatWallpaper; matchName?: string; + tier: SubscriptionTier; + scopeType: 'product' | 'connection'; }) { + const canSaveChats = isPaidSubscriptionTier(tier); + const [savedOpen, setSavedOpen] = useState(false); const { messages, loading, mood, pendingReview, availability, send, confirmReview, cancelReview } = chat; const [input, setInput] = useState(''); const endRef = useRef(null); @@ -1650,9 +1659,49 @@ function MrOneHookChatBody({ return ( <> + {canSaveChats && ( + { + if (!savedOpen) void chat.refreshSessions(); + setSavedOpen((v) => !v); + }} + /> + )}
+ {chat.sessionLimit && ( +
+

+ You have {chat.sessionLimit.maxSessions} saved chats, the most you can keep. Replace your least + recent chat, or delete one above. +

+
+ + +
+
+ )} {messages.length === 0 && !loading ? (
@@ -1736,6 +1785,102 @@ function MrOneHookChatBody({ ); } +/** + * Super and above: the member's saved Mr. OneHook chats (at most 3 in total, across product and match + * chats). Resume a product chat here, delete any, or start a new one. + */ +function SavedChatsBar({ + chat, + scopeType, + open, + onToggle, +}: { + chat: ReturnType; + scopeType: 'product' | 'connection'; + open: boolean; + onToggle: () => void; +}) { + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const list: SessionListItem[] = chat.sessionLimit?.sessions ?? chat.sessions; + + const remove = async (sessionId: string) => { + setBusy(sessionId); + setError(null); + try { + await chat.deleteSession(sessionId); + } catch { + setError("Couldn't delete that chat. Please try again."); + } finally { + setBusy(null); + } + }; + + return ( +
+
+ + +
+ {open && ( +
    + {list.length === 0 &&
  • No saved chats yet.
  • } + {list.map((s) => ( +
  • + + {s.scope === 'product' ? 'OneHook chat' : 'Match chat'} ·{' '} + {Number.isFinite(Date.parse(s.updatedAt)) ? new Date(s.updatedAt).toLocaleString() : ''} ·{' '} + {s.messageCount} messages + + + {s.scope === scopeType && scopeType === 'product' && ( + + )} + + +
  • + ))} +
+ )} + {error &&

{error}

} +
+ ); +} + /** * The consent gate. Shows the EXACT excerpts that would be shared (speaker, time, text) and sends * nothing until the user approves. Mirrors the privacy contract: the user sees precisely what leaves diff --git a/src/features/profile/CurrentSuggestionCard.tsx b/src/features/profile/CurrentSuggestionCard.tsx index dbda403..6a96c9e 100644 --- a/src/features/profile/CurrentSuggestionCard.tsx +++ b/src/features/profile/CurrentSuggestionCard.tsx @@ -1,24 +1,33 @@ -import { useState } from 'react'; -import { Sparkles } from 'lucide-react'; -import { SuggestionApi } from '../../api/rest'; -import { trackProps, UiElement } from '../../lib/analytics/analytics'; +import { useCallback, useEffect, useState } from 'react'; +import { Sparkles, UserRound } from 'lucide-react'; +import { SuggestionApi, type MemberSuggestions, type SuggestionView } from '../../api/rest'; +import { trackProps, UiElement, type UiElementId } from '../../lib/analytics/analytics'; -export interface CurrentSuggestion { - suggestionId: string; - reasoning: string; - createdAt: string; -} +export type CurrentSuggestion = SuggestionView; + +/** Server limits for a suggestion (Matching `SuggestionService`). */ +export const OWN_PROMPT_MIN = 3; +export const OWN_PROMPT_MAX = 1000; +export const OWN_NOTE_MAX = 280; /** - * The member's current Mr. OneHook request, shown in their match settings. One at a time: asking Mr. OneHook - * again replaces it; Remove is the only other way it goes away. Nothing renders when there is none. + * One suggestion card with Remove. Used for both the member's own suggestion and Mr. OneHook's; each is + * removed on its own. Nothing renders when there is none. */ export function CurrentSuggestionCard({ suggestion, - onRemoved, + title, + hint, + onRemove, + removeElement, + testId, }: { suggestion?: CurrentSuggestion | null; - onRemoved: () => void; + title: string; + hint: string; + onRemove: () => Promise; + removeElement: UiElementId; + testId: string; }) { const [removing, setRemoving] = useState(false); const [error, setError] = useState(null); @@ -32,8 +41,7 @@ export function CurrentSuggestionCard({ setRemoving(true); setError(null); try { - await SuggestionApi.remove(); - onRemoved(); + await onRemove(); } catch { setError("Couldn't remove it right now. Please try again."); } finally { @@ -42,20 +50,21 @@ export function CurrentSuggestionCard({ }; return ( -
+
- Your Mr. OneHook request + {title}

{suggestion.reasoning}

- {since ? `Since ${since}. ` : ''}Asking Mr. OneHook again replaces it. + {since ? `Since ${since}. ` : ''} + {hint}
); } + +/** + * "Tell OneHook who you're looking for" (Super and above). Saving replaces the member's previous own + * suggestion; Mr. OneHook's suggestion is separate and unaffected. + */ +export function OwnSuggestionForm({ onSaved, replacing }: { onSaved: () => void; replacing: boolean }) { + const [prompt, setPrompt] = useState(''); + const [note, setNote] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const text = prompt.trim(); + const valid = text.length >= OWN_PROMPT_MIN && text.length <= OWN_PROMPT_MAX && note.trim().length <= OWN_NOTE_MAX; + + const save = async () => { + if (!valid) return; + setSaving(true); + setError(null); + try { + // The note is what the member sees on the cards it surfaces; default to their own words. + const reasoning = (note.trim() || `You asked for: ${text}`).slice(0, OWN_NOTE_MAX); + await SuggestionApi.saveOwn(text, reasoning); + setPrompt(''); + setNote(''); + onSaved(); + } catch { + setError("Couldn't save it right now. Please try again."); + } finally { + setSaving(false); + } + }; + + return ( +
+ +