From 529b9e31a1efce9bfdf4d26d02cb3b439c272d37 Mon Sep 17 00:00:00 2001 From: Johnathan Reale Date: Wed, 5 Nov 2025 10:48:45 -0500 Subject: [PATCH 01/36] IP address setting, bindings --- .../packages/app-store/chain/Cargo.toml | 2 +- .../hns-indexer/hns-indexer/Cargo.toml | 2 +- .../hypermap-cacher/binding-cacher/Cargo.toml | 2 +- .../hypermap-cacher/Cargo.toml | 2 +- .../hypermap-cacher/reset-cache/Cargo.toml | 2 +- .../hypermap-cacher/set-nodes/Cargo.toml | 2 +- .../start-providing/Cargo.toml | 2 +- .../hypermap-cacher/stop-providing/Cargo.toml | 2 +- hyperdrive/src/net/mod.rs | 10 +- hyperdrive/src/register-ui/src/App.tsx | 16 ++ .../src/register-ui/src/abis/helpers.ts | 24 +-- hyperdrive/src/register-ui/src/lib/types.ts | 9 + .../register-ui/src/pages/CommitDotOsName.tsx | 70 ++++++++ .../src/register-ui/src/pages/Login.tsx | 52 +++++- .../src/register-ui/src/pages/MintCustom.tsx | 85 +++++++++- .../register-ui/src/pages/MintDotOsName.tsx | 5 +- .../src/register-ui/src/pages/ResetName.tsx | 160 +++++++++++++----- .../src/register-ui/src/pages/SetPassword.tsx | 9 +- hyperdrive/src/register.rs | 118 +++++++++++-- lib/src/kernel.rs | 20 ++- 20 files changed, 504 insertions(+), 90 deletions(-) diff --git a/hyperdrive/packages/app-store/chain/Cargo.toml b/hyperdrive/packages/app-store/chain/Cargo.toml index f859d495e..2637271c8 100644 --- a/hyperdrive/packages/app-store/chain/Cargo.toml +++ b/hyperdrive/packages/app-store/chain/Cargo.toml @@ -11,7 +11,7 @@ alloy-primitives = "0.8.15" alloy-sol-types = "0.8.15" anyhow = "1.0" bincode = "1.3.3" -hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "e95ff8d" } +hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "a420cc2" } process_macros = "0.1" rand = "0.8" serde = { version = "1.0", features = ["derive"] } diff --git a/hyperdrive/packages/hns-indexer/hns-indexer/Cargo.toml b/hyperdrive/packages/hns-indexer/hns-indexer/Cargo.toml index 93d00cb3d..a34f21890 100644 --- a/hyperdrive/packages/hns-indexer/hns-indexer/Cargo.toml +++ b/hyperdrive/packages/hns-indexer/hns-indexer/Cargo.toml @@ -11,7 +11,7 @@ anyhow = "1.0" alloy-primitives = "0.8.15" alloy-sol-types = "0.8.15" hex = "0.4.3" -hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "e95ff8d" , features = ["logging"] } +hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "a420cc2" , features = ["logging"] } process_macros = "0.1" rmp-serde = "1.1.2" serde = { version = "1.0", features = ["derive"] } diff --git a/hyperdrive/packages/hypermap-cacher/binding-cacher/Cargo.toml b/hyperdrive/packages/hypermap-cacher/binding-cacher/Cargo.toml index 4341c9353..c12e544e7 100644 --- a/hyperdrive/packages/hypermap-cacher/binding-cacher/Cargo.toml +++ b/hyperdrive/packages/hypermap-cacher/binding-cacher/Cargo.toml @@ -18,7 +18,7 @@ alloy = { version = "0.8.1", features = [ ] } chrono = "0.4.41" hex = "0.4.3" -hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "e95ff8d", features = ["logging"] } +hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "a420cc2", features = ["logging"] } process_macros = "0.1.0" rand = "0.8" rmp-serde = "1.1.2" diff --git a/hyperdrive/packages/hypermap-cacher/hypermap-cacher/Cargo.toml b/hyperdrive/packages/hypermap-cacher/hypermap-cacher/Cargo.toml index 730b54bd5..b1730eb93 100644 --- a/hyperdrive/packages/hypermap-cacher/hypermap-cacher/Cargo.toml +++ b/hyperdrive/packages/hypermap-cacher/hypermap-cacher/Cargo.toml @@ -18,7 +18,7 @@ alloy = { version = "0.8.1", features = [ ] } chrono = "0.4.41" hex = "0.4.3" -hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "e95ff8d", features = ["logging"] } +hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "a420cc2", features = ["logging"] } process_macros = "0.1.0" rand = "0.8" rmp-serde = "1.1.2" diff --git a/hyperdrive/packages/hypermap-cacher/reset-cache/Cargo.toml b/hyperdrive/packages/hypermap-cacher/reset-cache/Cargo.toml index 6e59bb18c..2f513bc6f 100644 --- a/hyperdrive/packages/hypermap-cacher/reset-cache/Cargo.toml +++ b/hyperdrive/packages/hypermap-cacher/reset-cache/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" publish = false [dependencies] -hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "e95ff8d" } +hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "a420cc2" } process_macros = "0.1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/hyperdrive/packages/hypermap-cacher/set-nodes/Cargo.toml b/hyperdrive/packages/hypermap-cacher/set-nodes/Cargo.toml index d8a3cd911..f44e5bca6 100644 --- a/hyperdrive/packages/hypermap-cacher/set-nodes/Cargo.toml +++ b/hyperdrive/packages/hypermap-cacher/set-nodes/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" publish = false [dependencies] -hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "e95ff8d" } +hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "a420cc2" } process_macros = "0.1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/hyperdrive/packages/hypermap-cacher/start-providing/Cargo.toml b/hyperdrive/packages/hypermap-cacher/start-providing/Cargo.toml index d94d592d5..dc3d9fae4 100644 --- a/hyperdrive/packages/hypermap-cacher/start-providing/Cargo.toml +++ b/hyperdrive/packages/hypermap-cacher/start-providing/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" publish = false [dependencies] -hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "e95ff8d" } +hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "a420cc2" } process_macros = "0.1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/hyperdrive/packages/hypermap-cacher/stop-providing/Cargo.toml b/hyperdrive/packages/hypermap-cacher/stop-providing/Cargo.toml index 96c75941f..f51c92c12 100644 --- a/hyperdrive/packages/hypermap-cacher/stop-providing/Cargo.toml +++ b/hyperdrive/packages/hypermap-cacher/stop-providing/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" publish = false [dependencies] -hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "e95ff8d" } +hyperware_process_lib = { git = "https://github.com/hyperware-ai/process_lib", rev = "a420cc2" } process_macros = "0.1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/hyperdrive/src/net/mod.rs b/hyperdrive/src/net/mod.rs index c55e0c94e..6a240bd35 100644 --- a/hyperdrive/src/net/mod.rs +++ b/hyperdrive/src/net/mod.rs @@ -88,11 +88,11 @@ pub async fn networking( match &ext.our.routing { NodeRouting::Direct { ip, ports } => { if *ext.our_ip != *ip { - return Err(anyhow::anyhow!( - "net: fatal error: IP address mismatch: {} != {}, update your HNS identity", - ext.our_ip, - ip - )); + let message = format!( + "IP address mismatch detected. Detected from environment: {}, node's data: {}. Please confirm your node is reachable at {} or restart your node to reset your networking data.", + ext.our_ip, ip, ip + ); + utils::print_loud(&ext.print_tx, &message).await; } utils::print_debug(&ext.print_tx, "going online as a direct node").await; if !ports.contains_key(WS_PROTOCOL) && !ports.contains_key(TCP_PROTOCOL) { diff --git a/hyperdrive/src/register-ui/src/App.tsx b/hyperdrive/src/register-ui/src/App.tsx index a6d54658e..5e5f480a0 100644 --- a/hyperdrive/src/register-ui/src/App.tsx +++ b/hyperdrive/src/register-ui/src/App.tsx @@ -23,6 +23,7 @@ function App() { const [keyFileName, setKeyFileName] = useState(''); const [reset, setReset] = useState(false); const [direct, setDirect] = useState(false); + const [directNodeIp, setDirectNodeIp] = useState(''); const [hnsName, setHnsName] = useState(''); const [networkingKey, setNetworkingKey] = useState(''); const [ipAddress, setIpAddress] = useState(0); @@ -72,6 +73,20 @@ function App() { } catch (e) { console.error('error getting current chain', e) } + + try { + const ipv4Response = await fetch('/ipv4', { method: 'GET', credentials: 'include' }) + + if (ipv4Response.status < 400) { + const ipv4Data: { ip: string } = await ipv4Response.json() + setDirectNodeIp(ipv4Data.ip) + console.log('IPv4 Address:', ipv4Data.ip) + } else { + console.error('error processing IPv4 response', ipv4Response) + } + } catch (e) { + console.error('error getting IPv4 address', e) + } })() }, []) // eslint-disable-line react-hooks/exhaustive-deps @@ -82,6 +97,7 @@ function App() { // todo, most of these can be removed... const props = { direct, setDirect, + directNodeIp, setDirectNodeIp, key, keyFileName, setKeyFileName, reset, setReset, diff --git a/hyperdrive/src/register-ui/src/abis/helpers.ts b/hyperdrive/src/register-ui/src/abis/helpers.ts index cd2db8602..bce823360 100644 --- a/hyperdrive/src/register-ui/src/abis/helpers.ts +++ b/hyperdrive/src/register-ui/src/abis/helpers.ts @@ -1,4 +1,3 @@ - import { NetworkingInfo } from "../lib/types"; import { hyperhash } from "../utils/hyperhash"; import { ipToBytes, portToBytes } from "../utils/hns_encoding"; @@ -13,15 +12,17 @@ const encodeRouters = (routers: string[]): `0x${string}` => { }; export const generateNetworkingKeys = async ({ - direct, - setNetworkingKey, - setWsPort, - setTcpPort, - setRouters, - reset, - customRouters, -}: { + direct, + directNodeIp, + setNetworkingKey, + setWsPort, + setTcpPort, + setRouters, + reset, + customRouters, + }: { direct: boolean, + directNodeIp?: string, label: string, our_address: `0x${string}`, setNetworkingKey: (networkingKey: string) => void; @@ -45,7 +46,9 @@ export const generateNetworkingKeys = async ({ (res) => res.json() )) as NetworkingInfo; - const ipAddress = ipToBytes(ip_address); + // Use directNodeIp if provided and direct is true, otherwise use the generated IP + const ipToUse = direct && directNodeIp ? directNodeIp : ip_address; + const ipAddress = ipToBytes(ipToUse); const routersToUse = customRouters && customRouters.length > 0 ? customRouters : allowed_routers; @@ -56,6 +59,7 @@ export const generateNetworkingKeys = async ({ setRouters(routersToUse); console.log("networking_key: ", networking_key); + console.log("IP address being used: ", ipToUse); console.log("routers being used: ", routersToUse); const netkeycall = encodeFunctionData({ diff --git a/hyperdrive/src/register-ui/src/lib/types.ts b/hyperdrive/src/register-ui/src/lib/types.ts index f3afbbacc..15be73f08 100644 --- a/hyperdrive/src/register-ui/src/lib/types.ts +++ b/hyperdrive/src/register-ui/src/lib/types.ts @@ -12,6 +12,8 @@ export interface PageProps { setRouters: React.Dispatch>, direct: boolean, setDirect: React.Dispatch>, + directNodeIp: string, + setDirectNodeIp: React.Dispatch>, hnsName: string, setHnsName: React.Dispatch>, key: string, @@ -49,6 +51,13 @@ export type InfoResponse = { allowed_routers?: string[]; initial_cache_sources: string[]; initial_base_l2_providers: string[]; + uses_direct_networking?: boolean; + hns_ip_address?: string; + detected_ip_address?: string; +} + +export type IPv4Response = { + ip: string; } export interface RpcProviderConfig { diff --git a/hyperdrive/src/register-ui/src/pages/CommitDotOsName.tsx b/hyperdrive/src/register-ui/src/pages/CommitDotOsName.tsx index 2950e21ed..1d5f1eacf 100644 --- a/hyperdrive/src/register-ui/src/pages/CommitDotOsName.tsx +++ b/hyperdrive/src/register-ui/src/pages/CommitDotOsName.tsx @@ -1,3 +1,4 @@ + import { useState, useEffect, FormEvent, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { toAscii } from "idna-uts46-hx"; @@ -19,9 +20,14 @@ interface RegisterOsNameProps extends PageProps { } // Regex for valid router names (domain format) const ROUTER_NAME_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/; +// IPv4 validation regex +const IPV4_REGEX = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + function CommitDotOsName({ direct, setDirect, + directNodeIp, + setDirectNodeIp, setHnsName, setNetworkingKey, setIpAddress, @@ -54,6 +60,40 @@ function CommitDotOsName({ const [customRouters, setCustomRouters] = useState('') const [routerValidationErrors, setRouterValidationErrors] = useState([]) + // Track the initial IPv4 value to determine if it was auto-detected + const [initialDirectNodeIp, setInitialDirectNodeIp] = useState('') + + // Capture the initial directNodeIp value when component mounts + useEffect(() => { + setInitialDirectNodeIp(directNodeIp); + }, []); // Empty dependency array means this runs once on mount + + // Validation function for IPv4 + const isValidIPv4 = (ip: string): boolean => { + return IPV4_REGEX.test(ip); + }; + + // Check if direct node configuration is valid + const isDirectNodeValid = (): boolean => { + if (!direct) return true; // Not required if checkbox is unchecked + return directNodeIp.trim() !== '' && isValidIPv4(directNodeIp.trim()); + }; + + // Determine the appropriate label for the Direct Node IP field + const getDirectNodeIpLabel = (): string => { + const hasValidInitialIp = initialDirectNodeIp && isValidIPv4(initialDirectNodeIp); + + if (!hasValidInitialIp) { + return "Direct Node IP Address (IPv4)"; + } + + if (directNodeIp === initialDirectNodeIp) { + return "Direct Node IP Address (as detected)"; + } + + return "Direct Node IP Address (overridden by user)"; + }; + // Modified setDirect function - no longer clears custom routers const handleSetDirect = (value: boolean) => { setDirect(value); @@ -227,6 +267,35 @@ function CommitDotOsName({ Advanced Network Options
+ {direct && ( +
+ + setDirectNodeIp(e.target.value)} + placeholder="e.g., 192.168.1.100" + className={`input ${ + direct && !isDirectNodeValid() + ? 'border-red-500 focus:border-red-500' + : '' + }`} + /> + {direct && directNodeIp.trim() && !isValidIPv4(directNodeIp.trim()) && ( + + Please enter a valid IPv4 address + + )} + {direct && !directNodeIp.trim() && ( + + IP address is required for direct nodes + + )} +
+ )} {specifyRouters && (
@@ -273,6 +342,7 @@ function CommitDotOsName({ isPending || isConfirming || nameValidities.length !== 0 || + (direct && !isDirectNodeValid()) || (specifyRouters && !isCustomRoutersValid()) } > diff --git a/hyperdrive/src/register-ui/src/pages/Login.tsx b/hyperdrive/src/register-ui/src/pages/Login.tsx index 84709693b..fe6fb6189 100644 --- a/hyperdrive/src/register-ui/src/pages/Login.tsx +++ b/hyperdrive/src/register-ui/src/pages/Login.tsx @@ -1,4 +1,3 @@ - import { FormEvent, useCallback, useEffect, useState } from "react"; import { PageProps, InfoResponse } from "../lib/types"; import Loader from "../components/Loader"; @@ -40,6 +39,10 @@ function Login({ const [cacheSourceValidationErrors, setCacheSourceValidationErrors] = useState([]); const [specifyBaseL2AccessProviders, setSpecifyBaseL2AccessProviders] = useState(false); const [rpcProviders, setRpcProviders] = useState([]); + const [usesDirectNetworking, setUsesDirectNetworking] = useState(false); + const [hnsIpAddress, setHnsIpAddress] = useState(""); + const [detectedIpAddress, setDetectedIpAddress] = useState(""); + const [acknowledgeIpMismatch, setAcknowledgeIpMismatch] = useState(false); // Track initial states after data is loaded const [initialCacheSourcesChecked, setInitialCacheSourcesChecked] = useState(false); @@ -64,6 +67,17 @@ function Login({ setHnsName(infoData.name); } + // Set networking information + if (infoData.uses_direct_networking !== undefined) { + setUsesDirectNetworking(infoData.uses_direct_networking); + } + if (infoData.hns_ip_address) { + setHnsIpAddress(infoData.hns_ip_address); + } + if (infoData.detected_ip_address) { + setDetectedIpAddress(infoData.detected_ip_address); + } + // Prepopulate cache sources if (infoData.initial_cache_sources && infoData.initial_cache_sources.length > 0) { setCustomCacheSources(infoData.initial_cache_sources.join('\n')); @@ -133,6 +147,8 @@ function Login({ return errors; }; + const hasIpMismatch = usesDirectNetworking && hnsIpAddress && detectedIpAddress && hnsIpAddress !== detectedIpAddress; + // Handle custom cache sources change with validation const handleCustomCacheSourcesChange = (value: string) => { setCustomCacheSources(value); @@ -253,6 +269,12 @@ function Login({ rpcProviders.some(p => !p.url.trim() || !validateWebSocketUrl(p.url) || (p.auth && !p.auth.value.trim())) ); + // Check if login button should be disabled + const isLoginDisabled = + (specifyCacheSources && !isCustomCacheSourcesValid()) || + hasInvalidRpcProviders || + (hasIpMismatch && !acknowledgeIpMismatch); + return
{loading &&
@@ -281,6 +303,32 @@ function Login({ onChange={(e) => setPw(e.target.value)} autoFocus /> + {/* IP Mismatch Warning */} + {hasIpMismatch && ( +
+
+ ⚠️ IP Address Mismatch Detected +
+
+
HNS IP: {hnsIpAddress}
(your node's published Hypermap directory address)
+
Detected IP: {detectedIpAddress}
(your node's likely current address)
+
+
+ Please use Reset Password & Networking Info to reset your networking information, or acknowledge the following to proceed with login. +
+ +
+ )}
{/* Advanced Options Section */} @@ -356,7 +404,7 @@ function Login({ +
+ ); + } + + return ( +
+ {error && ( +
+ {error} + +
+ )} + +
+ {showAppsView ? ( + + ) : activeGroup ? ( + + ) : activeChat ? ( + + ) : ( + + )} +
+ + {runningApps.map((app) => ( + + ))} + + + + {shouldShowOmniButton && } + + + + + + + + {pendingJoin && ( + setPendingJoin(null)} + /> + )} +
+ ); +} + +export default App; diff --git a/hyperdrive/packages/homepage/ui/src/components/Calls/CallHistory.css b/hyperdrive/packages/homepage/ui/src/components/Calls/CallHistory.css new file mode 100644 index 000000000..5b66e570e --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Calls/CallHistory.css @@ -0,0 +1,6 @@ +.call-history-container { + display: flex; + flex-direction: column; + height: 100%; + padding: 20px; +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Calls/CallHistory.tsx b/hyperdrive/packages/homepage/ui/src/components/Calls/CallHistory.tsx new file mode 100644 index 000000000..d1dca2684 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Calls/CallHistory.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import './CallHistory.css'; + +const CallHistory: React.FC = () => { + return ( +
+
+ 📞 +

Voice Calls Coming Soon

+

Voice call functionality will be available in a future update.

+
+
+ ); +}; + +export default CallHistory; \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/ChatHeader.css b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatHeader.css new file mode 100644 index 000000000..aa1aaac90 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatHeader.css @@ -0,0 +1,85 @@ +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px; + background: var(--surface); + border-bottom: 1px solid var(--border-color); + height: var(--header-height); +} + +.back-button { + background: none; + border: none; + font-size: 24px; + color: var(--primary-color); + padding: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.chat-header-info { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.chat-header-info .chat-avatar-wrap { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.chat-header-info.official-chat .chat-avatar-wrap { + filter: drop-shadow(0 0 8px rgba(45, 118, 255, 0.45)); +} + +.chat-header-info .official-glow { + position: absolute; + inset: -4px; + border-radius: 50%; + box-shadow: 0 0 0 2px rgba(45, 118, 255, 0.45), + 0 0 14px rgba(45, 118, 255, 0.55); + pointer-events: none; +} + +.chat-header-name { + font-size: 17px; + font-weight: 600; + color: var(--text-primary); + display: inline-flex; + align-items: center; + gap: 8px; +} + +.chat-header-name .official-badge { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.3px; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 999px; + background: linear-gradient(135deg, #2d76ff, #6dd5ff); + color: #fff; + box-shadow: 0 0 8px rgba(45, 118, 255, 0.45); +} + +.voice-call-button { + background: none; + border: none; + font-size: 24px; + padding: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/ChatHeader.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatHeader.tsx new file mode 100644 index 000000000..b838de9fb --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatHeader.tsx @@ -0,0 +1,51 @@ +import React, { useState } from 'react'; +import { Chat } from '#caller-utils'; +import { useChatStore } from '../../store/chat'; +import Avatar from '../Common/Avatar'; +import ChatSettings from './ChatSettings'; +import './ChatHeader.css'; + +interface ChatHeaderProps { + chat: Chat.Chat; +} + +const ChatHeader: React.FC = ({ chat }) => { + const { setActiveChat } = useChatStore(); + const [showSettings, setShowSettings] = useState(false); + const isOfficial = chat.counterparty === 'dao.hypr'; + + return ( + <> +
+ + +
+
+ + {isOfficial &&
+ + {chat.counterparty} + {isOfficial && official} + +
+ + +
+ + {showSettings && ( + setShowSettings(false)} /> + )} + + ); +}; + +export default ChatHeader; diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/ChatSettings.css b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatSettings.css new file mode 100644 index 000000000..f9ed4bc49 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatSettings.css @@ -0,0 +1,19 @@ +.chat-settings-modal { + background: var(--background); + border-radius: 12px; + width: 90%; + max-width: 400px; + padding: 0; +} + +.delete-chat-button { + background: var(--danger-color); + color: white; + border: none; + padding: 12px 24px; + border-radius: 8px; + font-size: 16px; + width: 100%; + margin-top: 20px; + cursor: pointer; +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/ChatSettings.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatSettings.tsx new file mode 100644 index 000000000..2a513bf4f --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatSettings.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Chat } from '#caller-utils'; +import { useChatStore } from '../../store/chat'; +import './ChatSettings.css'; + +interface ChatSettingsProps { + chat: Chat.Chat; + onClose: () => void; +} + +const ChatSettings: React.FC = ({ chat, onClose }) => { + const { deleteChat, updateChatSettings } = useChatStore(); + + const handleBlockToggle = () => { + // TODO: Implement block functionality + console.log('Block toggle'); + }; + + const handleNotifyToggle = async () => { + await updateChatSettings(chat.id, { notify: !chat.notify }); + }; + + const handleDeleteChat = async () => { + if (confirm('Are you sure you want to delete this chat?')) { + await deleteChat(chat.id); + onClose(); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Chat Settings

+ +
+ +
+
+ +
+ +
+ +
+ + +
+
+
+ ); +}; + +export default ChatSettings; \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/ChatView.css b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatView.css new file mode 100644 index 000000000..e875d00db --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatView.css @@ -0,0 +1,163 @@ +.chat-view { + display: flex; + flex-direction: column; + height: 100vh; + height: 100dvh; + background: var(--background); + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; +} + +.messages-container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 10px 15px; + padding-bottom: 20px; + -webkit-overflow-scrolling: touch; + min-height: 0; + position: relative; + /* Prevent iOS bounce when pulling down */ + overscroll-behavior-y: contain; +} + +/* Sync indicator for pull-to-refresh */ +.sync-indicator { + position: absolute; + top: 0; + left: 0; + right: 0; + background: var(--surface); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; + transition: height 0.2s ease-out, opacity 0.2s ease-out; + overflow: hidden; + z-index: 10; +} + +.sync-indicator-content { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--text-secondary); +} + +.sync-spinner { + animation: spin 1s linear infinite; + font-size: 18px; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Swipe navigation styles */ +.chat-view.swiping { + user-select: none; + -webkit-user-select: none; +} + +/* Offline tooltip styles */ +.offline-tooltip { + position: absolute; + top: 60px; /* Below the header */ + left: 10px; + right: 10px; + z-index: 100; + animation: slideDown 0.3s ease-out; +} + +.offline-tooltip-content { + background: rgba(0, 0, 0, 0.85); + color: white; + padding: 10px 15px; + border-radius: 10px; + font-size: 13px; + text-align: center; + backdrop-filter: blur(10px); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); +} + +@keyframes slideDown { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Scroll to bottom button */ +.scroll-to-bottom-button { + position: absolute; + bottom: 100px; /* Well above the message input to avoid overlap */ + right: 15px; + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--primary); + color: white; + border: none; + font-size: 18px; + line-height: 1; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease, opacity 0.2s ease; + z-index: 50; + animation: fadeIn 0.2s ease; +} + +/* Mobile-specific positioning */ +@media (max-width: 768px) { + .scroll-to-bottom-button { + bottom: 120px; /* Much higher on mobile to avoid input bar overlap */ + right: 12px; + width: 34px; + height: 34px; + font-size: 16px; + } +} + +.scroll-to-bottom-button:hover { + transform: scale(1.1); +} + +.scroll-to-bottom-button:active { + transform: scale(0.95); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Light mode */ +@media (prefers-color-scheme: light) { + .offline-tooltip-content { + background: rgba(50, 50, 50, 0.9); + } + + .scroll-to-bottom-button { + background: var(--primary); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); + } +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/ChatView.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatView.tsx new file mode 100644 index 000000000..483cd425a --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/ChatView.tsx @@ -0,0 +1,296 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { useChatStore } from '../../store/chat'; +import MessageList from './MessageList'; +import MessageInput from './MessageInput'; +import ChatHeader from './ChatHeader'; +import './ChatView.css'; +import { Chat } from '#caller-utils'; + +const ChatView: React.FC = () => { + const { + activeChat, + markChatAsRead, + setActiveChat, + forceSyncChat, + jumpToMessageId, + setJumpToMessageId + } = useChatStore(); + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + const [swipeX, setSwipeX] = useState(0); + const [isSwiping, setIsSwiping] = useState(false); + const [showScrollButton, setShowScrollButton] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [pullDistance, setPullDistance] = useState(0); + const startXRef = useRef(0); + const startYRef = useRef(0); + const lastScrollTopRef = useRef(0); + const isAtBottomRef = useRef(false); + const prevMessageCountRef = useRef(0); + const prevChatIdRef = useRef(null); + const touchStartYRef = useRef(0); + const chatViewRef = useRef(null); + + useEffect(() => { + if (activeChat) { + markChatAsRead(activeChat.id); + + } + }, [activeChat, markChatAsRead]); + + useEffect(() => { + if (!activeChat) return; + const messageCount = activeChat.messages.length; + const chatChanged = activeChat.id !== prevChatIdRef.current; + + if (jumpToMessageId) { + prevMessageCountRef.current = messageCount; + prevChatIdRef.current = activeChat.id; + return; + } + + if (chatChanged) { + messagesEndRef.current?.scrollIntoView({ behavior: 'instant' }); + } else if ( + messageCount > prevMessageCountRef.current && + isAtBottomRef.current + ) { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + + prevMessageCountRef.current = messageCount; + prevChatIdRef.current = activeChat.id; + }, [activeChat?.id, activeChat?.messages.length, jumpToMessageId]); + + useEffect(() => { + if (!activeChat || !jumpToMessageId) return; + const messageId = jumpToMessageId; + const timer = window.setTimeout(() => { + const element = document.getElementById(`message-${messageId}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + element.classList.add('highlight'); + window.setTimeout(() => element.classList.remove('highlight'), 2000); + } + setJumpToMessageId(null); + }, 50); + + return () => window.clearTimeout(timer); + }, [activeChat?.id, activeChat?.messages.length, jumpToMessageId, setJumpToMessageId]); + + // Check if scrolled to bottom for scroll button and sync trigger + useEffect(() => { + const container = messagesContainerRef.current; + if (!container) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + + // Check if at bottom + const isAtBottom = scrollHeight - scrollTop - clientHeight < 10; + isAtBottomRef.current = isAtBottom; + setShowScrollButton(!isAtBottom); + + // Store last scroll position + lastScrollTopRef.current = scrollTop; + }; + + container.addEventListener('scroll', handleScroll); + handleScroll(); // Check initial state + + return () => container.removeEventListener('scroll', handleScroll); + }, [activeChat]); + + // Scroll to bottom function + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + // Handle pull-to-refresh for syncing + const handleMessagesTouchStart = (e: React.TouchEvent) => { + const container = messagesContainerRef.current; + if (!container) return; + + // Check if we're at the bottom + const { scrollTop, scrollHeight, clientHeight } = container; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 10; + + if (isAtBottom) { + touchStartYRef.current = e.touches[0].clientY; + } + }; + + const handleMessagesTouchMove = (e: React.TouchEvent) => { + if (touchStartYRef.current === 0 || isSyncing) return; + + const container = messagesContainerRef.current; + if (!container) return; + + // Check if still at bottom + const { scrollTop, scrollHeight, clientHeight } = container; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 10; + + if (!isAtBottom) { + // User has scrolled up, cancel pull-to-refresh + touchStartYRef.current = 0; + setPullDistance(0); + return; + } + + const currentY = e.touches[0].clientY; + const deltaY = currentY - touchStartYRef.current; // Positive when pulling down + + // If pulling down past the bottom + if (deltaY > 10) { + // Prevent default to stop bounce on iOS + e.preventDefault(); + + const pull = Math.min(deltaY, 100); + setPullDistance(pull); + + // Add haptic feedback at threshold + if (pull >= 60 && pull < 65 && 'vibrate' in navigator) { + navigator.vibrate(10); + } + } + }; + + const handleMessagesTouchEnd = async () => { + if (pullDistance >= 60 && !isSyncing && activeChat) { + // Trigger sync + setIsSyncing(true); + setPullDistance(0); + + try { + console.log('[PULL-REFRESH] Syncing chat:', activeChat.id); + await forceSyncChat(activeChat.id); + } finally { + setIsSyncing(false); + } + } else { + setPullDistance(0); + } + + // Reset touch start + touchStartYRef.current = 0; + }; + + // Swipe handlers for navigation + const handleTouchStart = (e: React.TouchEvent) => { + // Only handle swipe if starting from left edge + if (e.touches[0].clientX < 30) { + startXRef.current = e.touches[0].clientX; + startYRef.current = e.touches[0].clientY; + setIsSwiping(false); + } + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (startXRef.current === 0) return; + + const currentX = e.touches[0].clientX; + const currentY = e.touches[0].clientY; + const deltaX = currentX - startXRef.current; + const deltaY = Math.abs(currentY - startYRef.current); + + // Only trigger swipe if horizontal movement is greater than vertical + // and swiping right from left edge + if (deltaX > 10 && deltaY < 50 && startXRef.current < 30) { + setIsSwiping(true); + // Limit swipe distance + const limitedDeltaX = Math.min(deltaX, window.innerWidth * 0.8); + setSwipeX(limitedDeltaX); + + // Add haptic feedback when reaching threshold + if (limitedDeltaX >= window.innerWidth * 0.3 && 'vibrate' in navigator) { + navigator.vibrate(10); + } + } + }; + + const handleTouchEnd = () => { + if (swipeX >= window.innerWidth * 0.3) { + // Go back to chat list + setActiveChat(null); + } + setSwipeX(0); + setIsSwiping(false); + startXRef.current = 0; + }; + + if (!activeChat) { + return null; + } + + return ( +
+ + +
+ {/* Pull-to-refresh indicator */} + {(pullDistance > 0 || isSyncing) && ( +
0 ? `${pullDistance}px` : '60px', + opacity: pullDistance > 0 ? Math.min(pullDistance / 60, 1) : 1 + }} + > +
+ {isSyncing ? ( + <> + + Syncing... + + ) : pullDistance >= 60 ? ( + <> + + Release to sync + + ) : ( + <> + + Pull to sync + + )} +
+
+ )} + + +
+
+ + {showScrollButton && ( + + )} + + +
+ ); +}; + +export default ChatView; diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/DeleteMessageModal.css b/hyperdrive/packages/homepage/ui/src/components/Chat/DeleteMessageModal.css new file mode 100644 index 000000000..8d17ed02c --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/DeleteMessageModal.css @@ -0,0 +1,76 @@ +.delete-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.delete-modal { + background: var(--background-secondary, #1a1a1a); + border-radius: 12px; + padding: 24px; + max-width: 400px; + width: 90%; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.delete-modal h3 { + margin: 0 0 12px 0; + color: var(--text-primary); + font-size: 20px; +} + +.delete-modal p { + color: var(--text-secondary); + margin-bottom: 24px; +} + +.delete-modal-buttons { + display: flex; + flex-direction: column; + gap: 12px; +} + +.delete-button { + padding: 12px 20px; + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: all 0.2s; + font-weight: 500; +} + +.delete-button.delete-locally { + background: var(--button-secondary, #333); + color: var(--text-primary); +} + +.delete-button.delete-locally:hover { + background: var(--button-secondary-hover, #444); +} + +.delete-button.delete-both { + background: #dc3545; + color: white; +} + +.delete-button.delete-both:hover { + background: #c82333; +} + +.delete-button.cancel { + background: transparent; + color: var(--text-secondary, #999); + border: 1px solid var(--border-color, #333); +} + +.delete-button.cancel:hover { + background: var(--background-tertiary, rgba(255,255,255,0.05)); +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/DeleteMessageModal.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/DeleteMessageModal.tsx new file mode 100644 index 000000000..fbb897425 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/DeleteMessageModal.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import './DeleteMessageModal.css'; + +interface DeleteMessageModalProps { + isOpen: boolean; + onClose: () => void; + onDeleteLocally: () => void; + onDeleteForBoth: () => void; + isOwnMessage: boolean; +} + +const DeleteMessageModal: React.FC = ({ + isOpen, + onClose, + onDeleteLocally, + onDeleteForBoth, + isOwnMessage +}) => { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +

Delete Message

+

Choose how you want to delete this message:

+ +
+ + + {isOwnMessage && ( + + )} + + +
+
+
+ ); +}; + +export default DeleteMessageModal; \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/FileUpload.css b/hyperdrive/packages/homepage/ui/src/components/Chat/FileUpload.css new file mode 100644 index 000000000..bbd623310 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/FileUpload.css @@ -0,0 +1,98 @@ +.file-upload-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: flex-end; + z-index: 1000; +} + +.file-upload-menu { + background: var(--background); + width: 100%; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.upload-option { + padding: 15px; + background: var(--surface); + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: background 0.2s; +} + +.upload-option:active { + background: var(--border-color); +} + +.upload-option label { + display: block; + width: 100%; + cursor: pointer; +} + +/* Upload progress styles */ +.upload-progress-container { + background: var(--surface); + border-radius: 8px; + padding: 15px; + margin-bottom: 10px; +} + +.upload-progress-item { + margin-bottom: 15px; +} + +.upload-progress-item:last-child { + margin-bottom: 0; +} + +.upload-filename { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-progress-bar { + height: 6px; + background: var(--border-color); + border-radius: 3px; + overflow: hidden; + margin-bottom: 5px; +} + +.upload-progress-fill { + height: 100%; + background: var(--primary); + border-radius: 3px; + transition: width 0.3s ease; +} + +.upload-progress-text { + font-size: 12px; + color: var(--text-secondary); + text-align: right; +} + +.upload-progress-item.has-error .upload-filename { + color: #ff4d4d; +} + +.upload-error { + font-size: 12px; + color: #ff4d4d; + margin-top: 4px; +} \ No newline at end of file diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/FileUpload.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/FileUpload.tsx new file mode 100644 index 000000000..c68e5cbe4 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/FileUpload.tsx @@ -0,0 +1,183 @@ +import React, { useState, useRef, useCallback } from 'react'; +import './FileUpload.css'; +import { useChatStore } from '../../store/chat'; +import * as Caller from '#caller-utils'; + +interface FileUploadProps { + onClose: () => void; +} + +interface UploadStatus { + progress: number; + error?: string; +} + +const { upload_file } = Caller.Chat; + +const FileUpload: React.FC = ({ onClose }) => { + const { activeChat, settings } = useChatStore(); + const [isUploading, setIsUploading] = useState(false); + const [uploadStatus, setUploadStatus] = useState<{ [filename: string]: UploadStatus }>({}); + const pendingUploadsRef = useRef(0); + + const readFileAsBase64 = useCallback((file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onprogress = (event) => { + if (event.lengthComputable) { + const percentComplete = (event.loaded / event.total) * 50; // 50% for reading + setUploadStatus(prev => ({ + ...prev, + [file.name]: { ...prev[file.name], progress: percentComplete } + })); + } + }; + + reader.onload = (event) => { + if (event.target?.result) { + const base64 = (event.target.result as string).split(',')[1]; + resolve(base64); + } else { + reject(new Error('Failed to read file')); + } + }; + + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); + }, []); + + const uploadSingleFile = useCallback(async (file: File, chatId: string, maxSizeBytes: number) => { + const filename = file.name; + + // Check file size + if (file.size > maxSizeBytes) { + const maxMb = Math.round(maxSizeBytes / (1024 * 1024)); + setUploadStatus(prev => ({ + ...prev, + [filename]: { progress: 0, error: `Exceeds ${maxMb}MB limit` } + })); + return; + } + + // Set initial progress + setUploadStatus(prev => ({ ...prev, [filename]: { progress: 0 } })); + + try { + const base64 = await readFileAsBase64(file); + + // Update progress to show uploading + setUploadStatus(prev => ({ ...prev, [filename]: { progress: 50 } })); + + // Upload file + await upload_file({ + chat_id: chatId, + filename, + mime_type: file.type || 'application/octet-stream', + data: base64, + reply_to: null + }); + + // Mark as complete + setUploadStatus(prev => ({ ...prev, [filename]: { progress: 100 } })); + } catch (error) { + console.error('Error uploading file:', error); + setUploadStatus(prev => ({ + ...prev, + [filename]: { progress: 0, error: 'Upload failed' } + })); + } + }, [readFileAsBase64]); + + const handleFileSelect = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0 || !activeChat) return; + + const maxSizeBytes = (settings.max_file_size_mb || 10) * 1024 * 1024; + const fileArray = Array.from(files); + + setIsUploading(true); + pendingUploadsRef.current = fileArray.length; + + // Upload all files in parallel + await Promise.all( + fileArray.map(file => uploadSingleFile(file, activeChat.id, maxSizeBytes)) + ); + + // Close dialog after a short delay to show completion + setTimeout(() => { + setIsUploading(false); + // Check if all uploads succeeded (no errors) + const hasErrors = Object.values(uploadStatus).some(s => s.error); + if (!hasErrors) { + onClose(); + } + }, 1000); + }; + + return ( +
+
e.stopPropagation()}> + {/* Show upload progress if uploading */} + {Object.keys(uploadStatus).length > 0 && ( +
+ {Object.entries(uploadStatus).map(([filename, status]) => ( +
+
{filename}
+ {status.error ? ( +
{status.error}
+ ) : ( + <> +
+
+
+
{Math.round(status.progress)}%
+ + )} +
+ ))} +
+ )} + + {/* Show upload options when not uploading */} + {!isUploading && ( + <> + + + + + )} +
+
+ ); +}; + +export default FileUpload; diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/Message.css b/hyperdrive/packages/homepage/ui/src/components/Chat/Message.css new file mode 100644 index 000000000..c79a921ea --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/Message.css @@ -0,0 +1,261 @@ +.message { + max-width: 75%; + display: flex; + flex-direction: column; + gap: 0; + position: relative; + animation: message-enter 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + opacity: 0; + transform: translateY(8px) scale(0.96); +} + +/* Telegram-style message entrance */ +@keyframes message-enter { + 0% { + opacity: 0; + transform: translateY(8px) scale(0.96); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Disable animation for messages already in view on load */ +.message.no-animate { + animation: none; + opacity: 1; + transform: none; +} + +/* Time-based spacing */ +.message.spacing-tight { + margin-top: 2px; +} + +.message.spacing-wide { + margin-top: 6px; +} + +.message.own { + align-self: flex-end; +} + +.message.other { + align-self: flex-start; +} + +.message.official-message .message-content { + box-shadow: 0 0 0 1px rgba(45, 118, 255, 0.35), + 0 0 12px rgba(45, 118, 255, 0.25); +} + +.reply-to { + background: rgba(0, 0, 0, 0.05); + border-radius: 8px; + padding: 6px 10px; + margin-bottom: 8px; + border-left: 3px solid var(--primary-color); +} + +.message.own .reply-to { + background: rgba(255, 255, 255, 0.15); + border-left-color: rgba(255, 255, 255, 0.5); +} + +.reply-to-label { + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 2px; + font-weight: 500; +} + +.message.own .reply-to-label { + color: rgba(255, 255, 255, 0.8); +} + +.reply-to-content { + font-size: 13px; + color: var(--text-primary); + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +.message.own .reply-to-content { + color: white; + opacity: 0.9; +} + +.message-content { + padding: 8px 60px 18px 12px; /* Extra right padding for time, bottom for footer overlay */ + border-radius: 18px; + font-size: 16px; + line-height: 1.4; + word-wrap: break-word; + position: relative; + min-width: 100px; /* Ensure minimum width for short messages to prevent emoji-time overlap */ +} + +.message.own .message-content { + background: #007aff; /* iOS blue */ + color: white; + border-bottom-right-radius: 4px; +} + +.message.other .message-content { + padding: 8px 50px 18px 12px; /* Less right padding for other's messages (no status icon) */ + background: var(--message-other, #e5e5ea); + color: var(--text-primary); + border-bottom-left-radius: 4px; +} + +/* Dark mode overrides */ +@media (prefers-color-scheme: dark) { + .message.other .message-content { + background: #3a3a3c; /* Dark gray for dark mode */ + color: #ffffff; + } +} + +.message-footer { + position: absolute; + bottom: 2px; + right: 10px; + display: flex; + gap: 4px; + font-size: 10px; + color: rgba(255, 255, 255, 0.7); + z-index: 1; + white-space: nowrap; /* Keep time on single line */ +} + +.message.other .message-footer { + color: var(--text-secondary); + opacity: 0.7; +} + +.message-time { + opacity: 0.8; +} + +.message-status { + opacity: 0.8; +} + +.message-reactions { + position: absolute; + bottom: -8px; + left: 8px; + display: flex; + flex-wrap: wrap; + gap: 2px; + z-index: 2; +} + +.reaction { + padding: 1px 4px; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 10px; + background: rgba(255, 255, 255, 0.95); + font-size: 11px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + animation: reaction-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +@keyframes reaction-pop { + 0% { + opacity: 0; + transform: scale(0.5); + } + 70% { + transform: scale(1.15); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@media (prefers-color-scheme: dark) { + .reaction { + background: rgba(50, 50, 50, 0.95); + border-color: rgba(255, 255, 255, 0.2); + } +} + +.reaction:hover { + background: var(--primary-color); + color: white; + border-color: var(--primary-color); + transform: scale(1.1); +} + +.reaction:active { + transform: scale(0.95); +} + +.reaction.reacted { + background: var(--primary-color); + color: white; + border-color: var(--primary-color); +} + +/* Highlight animation for scrolled-to messages */ +.message.highlight { + animation: highlight-pulse 2s ease-out; +} + +@keyframes highlight-pulse { + 0% { + background-color: rgba(255, 200, 0, 0.3); + border-radius: 8px; + } + 100% { + background-color: transparent; + } +} + +/* Swipe to reply styles */ +.message.swiping { + user-select: none; + -webkit-user-select: none; +} + +.message.reply-triggered { + animation: reply-feedback 0.3s ease-out; +} + +@keyframes reply-feedback { + 0% { + transform: translateX(0); + } + 50% { + transform: translateX(10px); + } + 100% { + transform: translateX(0); + } +} + +/* Voice note / audio player styles */ +.dm-audio-wrapper { + background: #2c2c2e; + border-radius: 12px; + padding: 8px 12px; + margin: 2px 0; +} + +.dm-audio-player { + width: 100%; + min-width: 220px; + height: 32px; + border: none; + outline: none; + background: transparent; + display: block; +} diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/Message.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/Message.tsx new file mode 100644 index 000000000..0444e8991 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/Message.tsx @@ -0,0 +1,727 @@ +import React, { useState, useMemo, useRef, useEffect } from 'react'; +import { Chat } from '#caller-utils'; +import MessageMenu from './MessageMenu'; +import './Message.css'; +import * as Caller from '#caller-utils'; +import { useChatStore } from '../../store/chat'; +import ReactMarkdown from 'react-markdown'; +import remarkBreaks from 'remark-breaks'; +import remarkHwProtocol from '../../utils/remarkHwProtocol'; +import { normalizeMessageContent } from '../../utils/normalizeMessageContent'; +import { SpacingClass } from '../../utils/messageSpacing'; +import { getChatBasePath } from '../../utils/chatBase'; + +interface MessageProps { + message: Chat.ChatMessage; + isOwn: boolean; + spacingClass?: SpacingClass; +} + +const { add_reaction, remove_reaction } = Caller.Chat; + +const Message: React.FC = ({ message, isOwn, spacingClass = 'wide' }) => { + const [showMenu, setShowMenu] = useState(false); + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); + const [swipeX, setSwipeX] = useState(0); + const [isSwiping, setIsSwiping] = useState(false); + const [audioUrl, setAudioUrl] = useState(null); + const audioUrlRef = useRef(null); + const { activeChat, settings, setReplyingTo } = useChatStore(); + const messageRef = useRef(null); + const isOfficial = message.sender === 'dao.hypr'; + const startXRef = useRef(0); + const startYRef = useRef(0); + const longPressTimerRef = useRef | null>(null); + const touchStartTimeRef = useRef(0); + + // Clean up timer on unmount + useEffect(() => { + return () => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + } + }; + }, []); + + useEffect(() => { + return () => { + if (audioUrlRef.current && audioUrlRef.current.startsWith('blob:')) { + URL.revokeObjectURL(audioUrlRef.current); + } + }; + }, []); + + const formatTime = (timestamp: number) => { + const date = new Date(timestamp * 1000); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + }; + + const getStatusIcon = () => { + switch (message.status) { + case Chat.MessageStatus.Sending: + return '...'; + case Chat.MessageStatus.Sent: + return '✓'; + case Chat.MessageStatus.Delivered: + return '✓✓'; + case Chat.MessageStatus.Failed: + return '❌'; + default: + return ''; + } + }; + + const buildDownloadUrl = (rawUrl: string) => { + if (!rawUrl.startsWith('/')) { + return rawUrl; + } + + const basePath = getChatBasePath(); + + if (rawUrl.startsWith(`${basePath}/`)) { + return rawUrl; + } + + return `${basePath}${rawUrl}`; + }; + + const extractFileRef = (rawUrl: string) => { + const filesIndex = rawUrl.indexOf('/files/'); + if (filesIndex === -1) { + return null; + } + + const rest = rawUrl + .slice(filesIndex + '/files/'.length) + .split('?')[0] + .split('#')[0]; + const [chatId, fileId] = rest.split('/'); + + if (!chatId || !fileId) { + return null; + } + + return { chatId, fileId }; + }; + + const handleFileDownload = async (e: React.MouseEvent) => { + e.preventDefault(); + + if (!message.file_info) { + return; + } + + const downloadBlob = (blob: Blob) => { + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = message.file_info?.filename || 'download'; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 0); + }; + + const fileUrl = message.file_info.url; + if (fileUrl.startsWith('data:')) { + try { + const response = await fetch(fileUrl); + const blob = await response.blob(); + downloadBlob(blob); + } catch (error) { + console.error('Failed to download image data URL:', error); + } + return; + } + + const fileRef = extractFileRef(fileUrl); + if (!fileRef) { + console.error('Unsupported file URL:', fileUrl); + return; + } + + try { + const response = await fetch(buildDownloadUrl('/api/download-file'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + DownloadFile: { + chat_id: fileRef.chatId, + file_id: fileRef.fileId, + }, + }), + }); + if (!response.ok) { + throw new Error(`Download failed with status ${response.status}`); + } + + const json = await response.json(); + if (json && typeof json === 'object' && 'Err' in json) { + throw new Error((json as { Err: string }).Err); + } + + const payload = json && typeof json === 'object' && 'Ok' in json + ? (json as { Ok: unknown }).Ok + : json; + let mimeType = message.file_info.mime_type || 'application/octet-stream'; + let fileBytes: number[] | null = null; + + if (Array.isArray(payload)) { + if (payload.length === 2 && typeof payload[0] === 'string' && Array.isArray(payload[1])) { + mimeType = payload[0]; + fileBytes = payload[1] as number[]; + } else if (payload.every((value) => typeof value === 'number')) { + fileBytes = payload as number[]; + } + } + + if (!fileBytes) { + console.error('Unexpected file payload shape:', payload); + return; + } + + const blob = new Blob([new Uint8Array(fileBytes)], { type: mimeType }); + downloadBlob(blob); + } catch (error) { + console.error('Failed to download file:', error); + } + }; + + const handleLongPress = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + const rect = (e.target as HTMLElement).getBoundingClientRect(); + setMenuPosition({ x: rect.left, y: rect.top }); + setShowMenu(true); + }; + + const isAudioMessage = Boolean( + message.file_info && message.message_type === Chat.MessageType.VoiceNote, + ); + + useEffect(() => { + if (!isAudioMessage || !message.file_info) { + if (audioUrlRef.current && audioUrlRef.current.startsWith('blob:')) { + URL.revokeObjectURL(audioUrlRef.current); + } + audioUrlRef.current = null; + setAudioUrl(null); + return; + } + + const fileUrl = message.file_info.url; + if (fileUrl.startsWith('data:')) { + setAudioUrl(fileUrl); + return; + } + + const fileRef = extractFileRef(fileUrl); + if (!fileRef) { + return; + } + + let cancelled = false; + const fetchAudio = async () => { + try { + const response = await fetch(buildDownloadUrl('/api/download-file'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + DownloadFile: { + chat_id: fileRef.chatId, + file_id: fileRef.fileId, + }, + }), + }); + if (!response.ok) { + throw new Error(`Download failed with status ${response.status}`); + } + + const json = await response.json(); + if (json && typeof json === 'object' && 'Err' in json) { + throw new Error((json as { Err: string }).Err); + } + const payload = + json && typeof json === 'object' && 'Ok' in json ? (json as { Ok: unknown }).Ok : json; + let mimeType = message.file_info?.mime_type || 'audio/webm'; + let fileBytes: number[] | null = null; + + if (Array.isArray(payload)) { + if (payload.length === 2 && typeof payload[0] === 'string' && Array.isArray(payload[1])) { + mimeType = payload[0]; + fileBytes = payload[1] as number[]; + } else if (payload.every((value) => typeof value === 'number')) { + fileBytes = payload as number[]; + } + } + + if (!fileBytes || cancelled) { + return; + } + + const blob = new Blob([new Uint8Array(fileBytes)], { type: mimeType }); + const objectUrl = URL.createObjectURL(blob); + if (audioUrlRef.current && audioUrlRef.current.startsWith('blob:')) { + URL.revokeObjectURL(audioUrlRef.current); + } + audioUrlRef.current = objectUrl; + setAudioUrl(objectUrl); + } catch (error) { + console.error('Failed to load audio:', error); + } + }; + + fetchAudio(); + + return () => { + cancelled = true; + }; + }, [isAudioMessage, message.file_info?.url, message.file_info?.mime_type, message.message_type]); + + // Swipe handlers for swipe-to-reply + const handleTouchStart = (e: React.TouchEvent) => { + startXRef.current = e.touches[0].clientX; + startYRef.current = e.touches[0].clientY; + touchStartTimeRef.current = Date.now(); + setIsSwiping(false); + + // Start long press timer for iOS + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + } + + longPressTimerRef.current = setTimeout(() => { + // Trigger long press after 500ms + const touch = e.touches[0]; + const rect = messageRef.current?.getBoundingClientRect(); + if (rect) { + setMenuPosition({ x: touch.clientX, y: touch.clientY }); + setShowMenu(true); + // Haptic feedback for long press + if ('vibrate' in navigator) { + navigator.vibrate(10); + } + } + }, 500); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + const currentX = e.touches[0].clientX; + const currentY = e.touches[0].clientY; + const deltaX = currentX - startXRef.current; + const deltaY = Math.abs(currentY - startYRef.current); + + // Cancel long press if user moves finger too much + if (Math.abs(deltaX) > 10 || deltaY > 10) { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + } + + // Only trigger swipe if horizontal movement is greater than vertical + if (Math.abs(deltaX) > 10 && deltaY < 50) { + setIsSwiping(true); + // Limit swipe distance + const limitedDeltaX = Math.min(Math.max(deltaX, -80), 80); + setSwipeX(limitedDeltaX); + + // Add haptic feedback when reaching threshold + if (Math.abs(limitedDeltaX) >= 60 && 'vibrate' in navigator) { + navigator.vibrate(10); + } + } + }; + + const handleTouchEnd = () => { + // Clear long press timer + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + + // Check if it was a quick tap (less than 200ms) to prevent accidental swipe-to-reply + const touchDuration = Date.now() - touchStartTimeRef.current; + + if (Math.abs(swipeX) >= 60 && touchDuration > 200) { + // Trigger reply action + setReplyingTo(message); + // Add visual feedback + if (messageRef.current) { + messageRef.current.classList.add('reply-triggered'); + setTimeout(() => { + messageRef.current?.classList.remove('reply-triggered'); + }, 300); + } + } + setSwipeX(0); + setIsSwiping(false); + }; + + const handleReaction = async (emoji: string) => { + try { + const ourNode = (window as any).our?.node; + console.log('[REACTION] Our node:', ourNode); + console.log('[REACTION] Message reactions:', message.reactions); + + // Check if user already reacted with this emoji + const existingReaction = message.reactions?.find( + r => r.user === ourNode && r.emoji === emoji + ); + + console.log('[REACTION] Existing reaction found:', existingReaction); + console.log('[REACTION] Chat ID:', activeChat?.id, 'Message ID:', message.id); + + if (existingReaction) { + console.log('[REACTION] Removing reaction:', emoji); + const result = await remove_reaction({ + chat_id: activeChat?.id || '', + message_id: message.id, + emoji + }); + console.log('[REACTION] Remove reaction result:', result); + } else { + console.log('[REACTION] Adding reaction:', emoji); + const result = await add_reaction({ + chat_id: activeChat?.id || '', + message_id: message.id, + emoji + }); + console.log('[REACTION] Add reaction result:', result); + } + + // WebSocket will handle the update + } catch (err) { + console.error('[REACTION] Error handling reaction:', err); + } + }; + + const groupReactions = () => { + const grouped: { [emoji: string]: string[] } = {}; + message.reactions?.forEach(reaction => { + if (!grouped[reaction.emoji]) { + grouped[reaction.emoji] = []; + } + grouped[reaction.emoji].push(reaction.user); + }); + return grouped; + }; + + // Render message content with markdown support + const normalizedContent = useMemo( + () => normalizeMessageContent(message.content), + [message.content], + ); + + const renderMessageContent = useMemo(() => { + return ( + { + // Allow hw:// protocol links to pass through unchanged + if (url.startsWith('hw://')) { + return url; + } + // For other URLs, return as-is (React Markdown will handle security) + return url; + }} + components={{ + // Custom link rendering + a: ({ href, children }) => { + const imageRegex = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i; + const isHwProtocol = href?.startsWith('hw://'); + + // Check if link is an image URL + if (href && imageRegex.test(href) && settings?.show_images) { + return ( + + ); + } + + // hw:// protocol links - let hw-protocol-watcher handle them + if (isHwProtocol) { + return ( + + {children} + + ); + } + + // Regular link + return ( + + {children} + + ); + }, + // Custom paragraph rendering to handle spacing + p: ({ children }) => ( +

{children}

+ ), + // Custom code rendering + code: ({ children, ...props }) => { + const inline = !('className' in props && typeof props.className === 'string' && props.className.includes('language-')); + if (inline) { + return ( + + {children} + + ); + } + return ( +
+                {children}
+              
+ ); + }, + // Custom list rendering + ul: ({ children }) => ( +
    {children}
+ ), + ol: ({ children }) => ( +
    {children}
+ ), + // Custom blockquote rendering + blockquote: ({ children }) => ( +
+ {children} +
+ ), + // Custom heading rendering + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + // Custom image rendering + img: ({ src, alt }) => { + if (!settings?.show_images) return null; + return ( + {alt} + ); + }, + }} + > + {normalizedContent} +
+ ); + }, [normalizedContent, settings?.show_images, isOwn]); + + return ( + <> +
+ {message.reply_to && ( +
{ + // Scroll to the original message + const element = document.getElementById(`message-${message.reply_to}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Add a highlight animation + element.classList.add('highlight'); + setTimeout(() => element.classList.remove('highlight'), 2000); + } + }} + style={{ cursor: 'pointer' }} + > +
↩ Reply
+
+ {activeChat?.messages.find(m => m.id === message.reply_to)?.content || 'Message not found'} +
+
+ )} + +
+ {/* If this is a file/image message with file info, show it specially */} + {isAudioMessage && message.file_info ? ( +
+ {audioUrl ? ( +
+ ) : message.file_info && message.message_type === 'Image' && settings?.show_images ? ( +
+ {message.file_info.filename} +
+ + {message.file_info.filename} + +
+ {(message.file_info.size / 1024).toFixed(1)} KB +
+
+
+ ) : message.file_info && message.message_type === 'File' ? ( +
+ + {message.file_info.filename} + +
+ {(message.file_info.size / 1024).toFixed(1)} KB +
+
+ ) : ( + renderMessageContent + )} +
+ +
+ {formatTime(message.timestamp)} + {isOwn && ( + {getStatusIcon()} + )} +
+ + {message.reactions && message.reactions.length > 0 && ( +
+ {Object.entries(groupReactions()).map(([emoji, users]) => ( + + ))} +
+ )} +
+ + {showMenu && ( + setShowMenu(false)} + /> + )} + + ); +}; + +export default Message; diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/MessageInput.css b/hyperdrive/packages/homepage/ui/src/components/Chat/MessageInput.css new file mode 100644 index 000000000..9a83e1682 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/MessageInput.css @@ -0,0 +1,183 @@ +.message-input-wrapper { + background: var(--surface); + border-top: 1px solid var(--border-color); +} + +.reply-preview, +.edit-preview { + padding: 8px 15px; + background: var(--background); + border-bottom: 1px solid var(--border-color); + animation: preview-slide-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + transform-origin: bottom center; +} + +@keyframes preview-slide-in { + 0% { + opacity: 0; + transform: translateY(10px) scaleY(0.9); + max-height: 0; + } + 100% { + opacity: 1; + transform: translateY(0) scaleY(1); + max-height: 100px; + } +} + +.edit-preview { + border-left: 3px solid var(--primary-color); +} + +.reply-info, +.edit-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.reply-label, +.edit-label { + font-size: 12px; + color: var(--text-secondary); + font-weight: 500; +} + +.edit-label { + color: var(--primary-color); +} + +.cancel-reply, +.cancel-edit { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 2px 6px; + font-size: 16px; + line-height: 1; + border-radius: 4px; +} + +.cancel-reply:hover, +.cancel-edit:hover { + background: var(--surface); +} + +.reply-content, +.edit-content { + font-size: 13px; + color: var(--text-primary); + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.message-input-container { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 10px 15px; + padding-bottom: calc(10px + var(--safe-area-bottom)); +} + +.message-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.message-action-button { + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid var(--border-color); + background: var(--background); + font-size: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), + background 0.15s ease, + border-color 0.15s ease; +} + +.message-action-button:hover { + background: var(--surface); + border-color: var(--primary-color); + transform: scale(1.08); +} + +.message-action-button:active { + transform: scale(0.92); +} + +.message-action-button:disabled { + opacity: 0.5; + cursor: default; + transform: scale(1); +} + +.message-input-container.editing { + background: rgba(0, 122, 255, 0.05); +} + +.message-input { + flex: 1; + min-height: 36px; + max-height: 120px; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 18px; + background: var(--background); + font-size: 16px; + resize: none; + outline: none; + font-family: inherit; + transition: border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.message-input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.15); +} + +.send-button { + width: 36px; + height: 36px; + border-radius: 50%; + background: linear-gradient(135deg, #007aff, #5ac8fa); + color: white; + border: none; + font-size: 18px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), + opacity 0.15s ease, + box-shadow 0.15s ease; +} + +.send-button:not(:disabled):hover { + transform: scale(1.05); + box-shadow: 0 2px 8px rgba(0, 122, 255, 0.4); +} + +.send-button:not(:disabled):active { + transform: scale(0.85); +} + +.send-button:disabled { + opacity: 0.5; + cursor: default; + transform: scale(1); +} diff --git a/hyperdrive/packages/homepage/ui/src/components/Chat/MessageInput.tsx b/hyperdrive/packages/homepage/ui/src/components/Chat/MessageInput.tsx new file mode 100644 index 000000000..fb847dca2 --- /dev/null +++ b/hyperdrive/packages/homepage/ui/src/components/Chat/MessageInput.tsx @@ -0,0 +1,172 @@ +import React, { useState, useRef, useEffect } from 'react'; +import * as Caller from '#caller-utils'; +import { useChatStore } from '../../store/chat'; +import FileUpload from './FileUpload'; +import VoiceNote from './VoiceNote'; +import './MessageInput.css'; + +interface MessageInputProps { + chatId: string; + onSendMessage?: () => void; +} + +const MessageInput: React.FC = ({ chatId, onSendMessage }) => { + const [message, setMessage] = useState(''); + const [showFileUpload, setShowFileUpload] = useState(false); + const [showVoiceNote, setShowVoiceNote] = useState(false); + const { sendMessage, replyingTo, setReplyingTo, editingMessage, setEditingMessage, editMessage } = useChatStore(); + const inputRef = useRef(null); + + // Detect if user is on mobile device (avoid touch-enabled laptops) + const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); + + // Focus input when replying + useEffect(() => { + if (replyingTo) { + inputRef.current?.focus(); + } + }, [replyingTo]); + + // Focus input and populate when editing + useEffect(() => { + if (editingMessage) { + setMessage(editingMessage.content); + inputRef.current?.focus(); + } + }, [editingMessage]); + + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault(); + const messageText = message.trim(); + if (messageText) { + // Clear input immediately to prevent double send + setMessage(''); + + if (editingMessage) { + // Handle edit + const editId = editingMessage.id; + setEditingMessage(null); + if (messageText !== editingMessage.content) { + await editMessage(editId, messageText); + } + } else { + // Handle send + const replyToId = replyingTo?.id; + setReplyingTo(null); + await sendMessage(chatId, messageText, replyToId); + onSendMessage?.(); + } + inputRef.current?.focus(); + } + }; + + const handleCancelEdit = () => { + setEditingMessage(null); + setMessage(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // On desktop: Enter sends, Shift+Enter adds newline + // On mobile: Enter adds newline, send button must be used + if (e.key === 'Enter' && !e.shiftKey && !isMobile) { + e.preventDefault(); + handleSubmit(); + } + }; + + const handleSendVoiceNote = async (payload: { base64: string; duration: number; mimeType: string }) => { + const replyToId = replyingTo?.id || null; + setReplyingTo(null); + await Caller.Chat.send_voice_note({ + chat_id: chatId, + audio_data: payload.base64, + duration: payload.duration, + reply_to: replyToId, + }); + onSendMessage?.(); + }; + + return ( +
+ {editingMessage && ( +
+
+ Editing message + +
+
{editingMessage.content}
+
+ )} + {replyingTo && !editingMessage && ( +
+
+ Replying to {replyingTo.sender} + +
+
{replyingTo.content}
+
+ )} + +
+ {!editingMessage && ( +
+ + +
+ )} +