From de9e082d4da013843c77a78f78e7c7c98040ff7a Mon Sep 17 00:00:00 2001 From: ThinhKimVo Date: Thu, 16 Apr 2026 11:34:42 +0700 Subject: [PATCH 01/10] feat: integrate web chat support into rn-firebase-chat - Add web entry point (src/web.ts) with React web chat components - Add WebChatProvider, ChatScreen, ChatList, MessageInput, ChatHeader, ChatNewModal, UserAvatar, TypingIndicator, ConnectionStatus components - Add web services: firebase (web SDK), chat, user - Add web hooks: useChat, useMessages, useTyping, useChatSelector, useDebounce - Add web addons: fileUpload, gallery - Fix CSS import paths for ChatScreen.css in ChatScreen and ChatList - Fix ButtonMaterialIcon to use material-icons class and font - Fix ChatProvider to not pass undefined avatar to Firestore - Fix ChatService to only write initiator's user document (not partner's) - Fix tsconfig.build.json to emit declarations to lib/typescript correctly - Update package.json exports with ./web and ./web/styles.css entries - Update prepare script to use root tsc for declaration generation - Add example App.web.tsx and example/src/web/ for web demo - Remove camera addon from RN src (moved to web/addons/camera) Co-Authored-By: Claude Sonnet 4.6 --- example/.env.example | 8 + example/.gitignore | 1 + example/App.web.tsx | 106 + example/app.config.ts | 17 + example/package.json | 15 +- example/src/web/ChatScreen.tsx | 64 + packages/rn-firebase-chat/package.json | 60 +- packages/rn-firebase-chat/postcss.config.cjs | 7 + packages/rn-firebase-chat/src/setupTests.ts | 5 + packages/rn-firebase-chat/src/web.ts | 1 + .../src/web/addons/camera/CameraView.css | 204 ++ .../{ => web}/addons/camera/CameraView.tsx | 10 +- .../{ => web}/addons/camera/CaptureButton.tsx | 2 +- .../src/{ => web}/addons/camera/constants.ts | 2 +- .../{ => web}/addons/camera/enhancedHook.ts | 2 +- .../src/{ => web}/addons/camera/hook.ts | 2 +- .../src/{ => web}/addons/camera/index.ts | 0 .../src/{ => web}/addons/camera/interface.ts | 2 +- .../src/{ => web}/addons/camera/utilities.ts | 0 .../web/addons/fileUpload/FileUploader.css | 211 ++ .../web/addons/fileUpload/FileUploader.tsx | 241 ++ .../src/web/addons/fileUpload/index.ts | 9 + .../src/web/addons/fileUpload/types.ts | 43 + .../web/addons/fileUpload/useFileUpload.ts | 169 ++ .../src/web/addons/gallery/GalleryView.css | 177 ++ .../src/web/addons/gallery/GalleryView.tsx | 135 + .../src/web/addons/gallery/MediaViewer.css | 210 ++ .../src/web/addons/gallery/MediaViewer.tsx | 165 ++ .../src/web/addons/gallery/index.ts | 11 + .../src/web/addons/gallery/types.ts | 52 + .../src/web/addons/gallery/useGallery.ts | 70 + .../src/web/components/ButtonMaterialIcon.tsx | 58 + .../src/web/components/ChatHeader.tsx | 45 + .../src/web/components/ChatList.tsx | 270 ++ .../src/web/components/ChatNewModal.tsx | 143 + .../src/web/components/ChatScreen.css | 853 ++++++ .../src/web/components/ChatScreen.tsx | 842 ++++++ .../src/web/components/ConnectionStatus.tsx | 58 + .../src/web/components/MessageInput.tsx | 158 + .../src/web/components/MessageList.tsx | 444 +++ .../src/web/components/TypingIndicator.tsx | 60 + .../src/web/components/UserAvatar.tsx | 56 + .../src/web/context/ChatProvider.tsx | 163 ++ .../rn-firebase-chat/src/web/hooks/useChat.ts | 179 ++ .../src/web/hooks/useChatSelector.ts | 10 + .../src/web/hooks/useDebounce.ts | 1 + .../src/web/hooks/useMessages.ts | 111 + .../src/web/hooks/useTyping.ts | 74 + packages/rn-firebase-chat/src/web/index.ts | 24 + .../rn-firebase-chat/src/web/services/chat.ts | 573 ++++ .../src/web/services/firebase.ts | 86 + .../rn-firebase-chat/src/web/services/user.ts | 92 + packages/rn-firebase-chat/src/web/styles.css | 16 + .../rn-firebase-chat/src/web/styles/index.css | 282 ++ .../rn-firebase-chat/src/web/types/index.ts | 423 +++ .../rn-firebase-chat/src/web/utils/color.ts | 1 + .../src/web/utils/constants.ts | 9 + .../src/web/utils/conversation.ts | 18 + .../src/web/utils/encryption.ts | 35 + .../src/web/utils/formatters.ts | 239 ++ .../src/web/utils/security.ts | 23 + .../src/web/utils/validation.ts | 105 + packages/rn-firebase-chat/tailwind.config.js | 44 + packages/rn-firebase-chat/tsconfig.build.json | 12 +- pnpm-lock.yaml | 2596 ++++++++++++++++- 65 files changed, 9969 insertions(+), 135 deletions(-) create mode 100644 example/.env.example create mode 100644 example/App.web.tsx create mode 100644 example/src/web/ChatScreen.tsx create mode 100644 packages/rn-firebase-chat/postcss.config.cjs create mode 100644 packages/rn-firebase-chat/src/setupTests.ts create mode 100644 packages/rn-firebase-chat/src/web.ts create mode 100644 packages/rn-firebase-chat/src/web/addons/camera/CameraView.css rename packages/rn-firebase-chat/src/{ => web}/addons/camera/CameraView.tsx (97%) rename packages/rn-firebase-chat/src/{ => web}/addons/camera/CaptureButton.tsx (98%) rename packages/rn-firebase-chat/src/{ => web}/addons/camera/constants.ts (96%) rename packages/rn-firebase-chat/src/{ => web}/addons/camera/enhancedHook.ts (99%) rename packages/rn-firebase-chat/src/{ => web}/addons/camera/hook.ts (97%) rename packages/rn-firebase-chat/src/{ => web}/addons/camera/index.ts (100%) rename packages/rn-firebase-chat/src/{ => web}/addons/camera/interface.ts (97%) rename packages/rn-firebase-chat/src/{ => web}/addons/camera/utilities.ts (100%) create mode 100644 packages/rn-firebase-chat/src/web/addons/fileUpload/FileUploader.css create mode 100644 packages/rn-firebase-chat/src/web/addons/fileUpload/FileUploader.tsx create mode 100644 packages/rn-firebase-chat/src/web/addons/fileUpload/index.ts create mode 100644 packages/rn-firebase-chat/src/web/addons/fileUpload/types.ts create mode 100644 packages/rn-firebase-chat/src/web/addons/fileUpload/useFileUpload.ts create mode 100644 packages/rn-firebase-chat/src/web/addons/gallery/GalleryView.css create mode 100644 packages/rn-firebase-chat/src/web/addons/gallery/GalleryView.tsx create mode 100644 packages/rn-firebase-chat/src/web/addons/gallery/MediaViewer.css create mode 100644 packages/rn-firebase-chat/src/web/addons/gallery/MediaViewer.tsx create mode 100644 packages/rn-firebase-chat/src/web/addons/gallery/index.ts create mode 100644 packages/rn-firebase-chat/src/web/addons/gallery/types.ts create mode 100644 packages/rn-firebase-chat/src/web/addons/gallery/useGallery.ts create mode 100644 packages/rn-firebase-chat/src/web/components/ButtonMaterialIcon.tsx create mode 100644 packages/rn-firebase-chat/src/web/components/ChatHeader.tsx create mode 100644 packages/rn-firebase-chat/src/web/components/ChatList.tsx create mode 100644 packages/rn-firebase-chat/src/web/components/ChatNewModal.tsx create mode 100644 packages/rn-firebase-chat/src/web/components/ChatScreen.css create mode 100644 packages/rn-firebase-chat/src/web/components/ChatScreen.tsx create mode 100644 packages/rn-firebase-chat/src/web/components/ConnectionStatus.tsx create mode 100644 packages/rn-firebase-chat/src/web/components/MessageInput.tsx create mode 100644 packages/rn-firebase-chat/src/web/components/MessageList.tsx create mode 100644 packages/rn-firebase-chat/src/web/components/TypingIndicator.tsx create mode 100644 packages/rn-firebase-chat/src/web/components/UserAvatar.tsx create mode 100644 packages/rn-firebase-chat/src/web/context/ChatProvider.tsx create mode 100644 packages/rn-firebase-chat/src/web/hooks/useChat.ts create mode 100644 packages/rn-firebase-chat/src/web/hooks/useChatSelector.ts create mode 100644 packages/rn-firebase-chat/src/web/hooks/useDebounce.ts create mode 100644 packages/rn-firebase-chat/src/web/hooks/useMessages.ts create mode 100644 packages/rn-firebase-chat/src/web/hooks/useTyping.ts create mode 100644 packages/rn-firebase-chat/src/web/index.ts create mode 100644 packages/rn-firebase-chat/src/web/services/chat.ts create mode 100644 packages/rn-firebase-chat/src/web/services/firebase.ts create mode 100644 packages/rn-firebase-chat/src/web/services/user.ts create mode 100644 packages/rn-firebase-chat/src/web/styles.css create mode 100644 packages/rn-firebase-chat/src/web/styles/index.css create mode 100644 packages/rn-firebase-chat/src/web/types/index.ts create mode 100644 packages/rn-firebase-chat/src/web/utils/color.ts create mode 100644 packages/rn-firebase-chat/src/web/utils/constants.ts create mode 100644 packages/rn-firebase-chat/src/web/utils/conversation.ts create mode 100644 packages/rn-firebase-chat/src/web/utils/encryption.ts create mode 100644 packages/rn-firebase-chat/src/web/utils/formatters.ts create mode 100644 packages/rn-firebase-chat/src/web/utils/security.ts create mode 100644 packages/rn-firebase-chat/src/web/utils/validation.ts create mode 100644 packages/rn-firebase-chat/tailwind.config.js diff --git a/example/.env.example b/example/.env.example new file mode 100644 index 00000000..cb45fd6d --- /dev/null +++ b/example/.env.example @@ -0,0 +1,8 @@ +# Copy this file to .env and fill in your Firebase web config values + +EXPO_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key +EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=your_project.firebaseapp.com +EXPO_PUBLIC_FIREBASE_PROJECT_ID=your_project_id +EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=your_project.appspot.com +EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id +EXPO_PUBLIC_FIREBASE_APP_ID=your_app_id diff --git a/example/.gitignore b/example/.gitignore index 3207b8fe..96b65dd3 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -31,6 +31,7 @@ yarn-error.* *.pem # local env files +.env .env*.local # Firebase config (keep out of source control) diff --git a/example/App.web.tsx b/example/App.web.tsx new file mode 100644 index 00000000..ec5a01eb --- /dev/null +++ b/example/App.web.tsx @@ -0,0 +1,106 @@ +import React, { useEffect, useState } from 'react'; +import Constants from 'expo-constants'; +import { getAuth, signInAnonymously, onAuthStateChanged } from 'firebase/auth'; +import 'rn-firebase-chat/web/styles.css'; +import { initializeFirebase, IUser, UserService, WebChatProvider, ChatScreen } from 'rn-firebase-chat/web'; + +// Inject Material Icons font for icon buttons +if (typeof document !== 'undefined') { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://fonts.googleapis.com/icon?family=Material+Icons'; + document.head.appendChild(link); +} + +const expoConfig = Constants.expoConfig ?? Constants.manifest; +const extra = expoConfig?.extra ?? {}; + +const firebaseConfig = { + apiKey: + extra.firebaseApiKey || + process.env.EXPO_PUBLIC_FIREBASE_API_KEY || + process.env.FIREBASE_API_KEY || + '', + authDomain: + extra.firebaseAuthDomain || + process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN || + process.env.FIREBASE_AUTH_DOMAIN || + '', + projectId: + extra.firebaseProjectId || + process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID || + process.env.FIREBASE_PROJECT_ID || + '', + storageBucket: + extra.firebaseStorageBucket || + process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET || + process.env.FIREBASE_STORAGE_BUCKET || + '', + messagingSenderId: + extra.firebaseMessagingSenderId || + process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || + process.env.FIREBASE_MESSAGING_SENDER_ID || + '', + appId: + extra.firebaseAppId || + process.env.EXPO_PUBLIC_FIREBASE_APP_ID || + process.env.FIREBASE_APP_ID || + '', +}; + +initializeFirebase(firebaseConfig); + +export default function App() { + const [currentUser, setCurrentUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const auth = getAuth(); + const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => { + if (firebaseUser) { + const userService = UserService.getInstance(); + await userService.createUserIfNotExists(firebaseUser.uid, { name: 'Anonymous' }); + setCurrentUser({ id: firebaseUser.uid, name: 'Anonymous' }); + } else { + await signInAnonymously(auth); + } + setLoading(false); + }); + + return () => unsubscribe(); + }, []); + + if (loading) { + return ( +
+ Signing in... +
+ ); + } + + return ( + <> + +
+ {currentUser ? ( + + + + ) : null} +
+ + ); +} diff --git a/example/app.config.ts b/example/app.config.ts index e80774d4..3eb15db2 100644 --- a/example/app.config.ts +++ b/example/app.config.ts @@ -1,4 +1,7 @@ import { ExpoConfig } from 'expo/config'; +import dotenv from 'dotenv'; + +dotenv.config(); const config: ExpoConfig = { name: 'rn-firebase-chat-example', @@ -27,6 +30,20 @@ const config: ExpoConfig = { backgroundColor: '#E6F4FE', }, }, + extra: { + firebaseApiKey: + process.env.FIREBASE_API_KEY ?? process.env.EXPO_PUBLIC_FIREBASE_API_KEY ?? '', + firebaseAuthDomain: + process.env.FIREBASE_AUTH_DOMAIN ?? process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN ?? '', + firebaseProjectId: + process.env.FIREBASE_PROJECT_ID ?? process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID ?? '', + firebaseStorageBucket: + process.env.FIREBASE_STORAGE_BUCKET ?? process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET ?? '', + firebaseMessagingSenderId: + process.env.FIREBASE_MESSAGING_SENDER_ID ?? process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID ?? '', + firebaseAppId: + process.env.FIREBASE_APP_ID ?? process.env.EXPO_PUBLIC_FIREBASE_APP_ID ?? '', + }, plugins: [ '@react-native-firebase/app', [ diff --git a/example/package.json b/example/package.json index 0c1b2dec..c5ccbe38 100644 --- a/example/package.json +++ b/example/package.json @@ -6,7 +6,8 @@ "start": "expo start", "android": "expo run:android --active-arch-only", "ios": "expo run:ios", - "web": "expo start --web" + "web": "expo start --web", + "build:web": "expo build:web" }, "dependencies": { "@react-native-firebase/app": "^23.8.8", @@ -15,19 +16,29 @@ "@react-native-firebase/storage": "^23.8.8", "@react-navigation/native": "^7.2.0", "@react-navigation/native-stack": "^7.14.8", + "clsx": "^2.1.1", + "date-fns": "^2.30.0", + "framer-motion": "^11.18.2", + "react-hot-toast": "^2.4.1", + "react-textarea-autosize": "^8.5.9", "expo": "~55.0.12", "expo-build-properties": "^55.0.12", "expo-status-bar": "~55.0.4", + "firebase": "^10.14.1", "randomcolor": "^0.6.2", "react": "19.2.0", + "react-dom": "19.2.0", "react-native": "0.83.4", "react-native-aes-crypto": "^3.3.0", "react-native-screens": "^4.23.0", + "react-native-web": "~0.19.13", "rn-firebase-chat": "workspace:*" }, "devDependencies": { "@types/react": "~19.2.2", + "@types/react-dom": "~19.2.2", + "dotenv": "^16.1.4", "typescript": "~5.9.2" }, "private": true -} \ No newline at end of file +} diff --git a/example/src/web/ChatScreen.tsx b/example/src/web/ChatScreen.tsx new file mode 100644 index 00000000..104844d7 --- /dev/null +++ b/example/src/web/ChatScreen.tsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react'; +import { IUser, ChatScreen as LibChatScreen, WebChatProvider } from 'rn-firebase-chat/web'; + +interface Props { + currentUser: IUser; +} + +export default function ChatScreen({ currentUser }: Props) { + const [conversationId, setConversationId] = useState(''); + const [recipientId, setRecipientId] = useState(''); + const [activeConversation, setActiveConversation] = useState(null); + + const handleStart = (e: React.FormEvent) => { + e.preventDefault(); + if (!recipientId.trim()) return; + const ids = [currentUser.id, recipientId.trim()].sort(); + const convId = conversationId.trim() || ids.join('_'); + setActiveConversation(convId); + }; + + return ( +
+
+ Your ID: {currentUser.id} +
+ + {!activeConversation ? ( +
+
+

Start a conversation

+ setRecipientId(e.target.value)} + style={{ padding: 8, border: '1px solid #ddd', borderRadius: 8 }} + /> + setConversationId(e.target.value)} + style={{ padding: 8, border: '1px solid #ddd', borderRadius: 8 }} + /> + +
+
+ ) : ( +
+
+ + Conversation: {activeConversation} +
+ + + +
+ )} +
+ ); +} diff --git a/packages/rn-firebase-chat/package.json b/packages/rn-firebase-chat/package.json index 763b4560..0e587541 100644 --- a/packages/rn-firebase-chat/package.json +++ b/packages/rn-firebase-chat/package.json @@ -1,11 +1,26 @@ { "name": "rn-firebase-chat", "version": "0.7.2", - "description": "React Native Firebase Chat", + "description": "React Native and Web Firebase Chat library with real-time messaging, user authentication, and modern UI components", "type": "module", "main": "lib/commonjs/index.js", "module": "lib/module/index.js", "types": "lib/typescript/index.d.ts", + "style": "lib/styles.css", + "exports": { + ".": { + "import": "./lib/module/index.js", + "require": "./lib/commonjs/index.js", + "types": "./lib/typescript/index.d.ts" + }, + "./web": { + "import": "./lib/module/web.js", + "require": "./lib/commonjs/web.js", + "types": "./lib/typescript/web.d.ts" + }, + "./styles": "./lib/styles.css", + "./web/styles.css": "./lib/styles.css" + }, "files": [ "lib", "README.md", @@ -14,10 +29,11 @@ "scripts": { "test": "jest", "test:ci": "jest --maxWorkers=2 --coverage", - "typecheck": "tsc --noEmit", + "typecheck": "../../node_modules/.bin/tsc --noEmit", "typecheck:build": "tsc --project tsconfig.build.json --noEmit", "typecheck:example": "tsc --project ../../example/tsconfig.json --noEmit", - "build:types": "tsc --project tsconfig.build.json --emitDeclarationOnly", + "build:types": "../../node_modules/.bin/tsc --project tsconfig.build.json --emitDeclarationOnly --noEmitOnError false --skipLibCheck", + "build:css": "../../node_modules/.bin/postcss src/web/styles/index.css -o lib/styles.css", "clean:maps": "find lib -name '*.map' -delete", "lint": "../../node_modules/.bin/eslint \"**/*.{js,ts,tsx}\"", "prepack": "bob build --target commonjs && bob build --target module && (bob build --target typescript || true) && pnpm clean:maps", @@ -31,6 +47,8 @@ "react-native", "ios", "android", + "web", + "react", "chat", "firebase", "firestore", @@ -98,6 +116,17 @@ "react-native-video": "^6.15.0", "react-native-vision-camera": "^4.7.0", "release-it": "^19.0.3", + "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^14.3.1", + "@types/react-dom": "^18.3.7", + "autoprefixer": "^10.4.16", + "firebase": "^10.14.1", + "identity-obj-proxy": "^3.0.0", + "jest-environment-jsdom": "^29.7.0", + "postcss": "^8.4.32", + "postcss-cli": "^11.0.0", + "tailwindcss": "^3.3.6", "typescript": "^5.8.3", "typescript-eslint": "^8.0.0" }, @@ -107,6 +136,7 @@ "@react-native-firebase/storage": "*", "randomcolor": "*", "react": "*", + "react-dom": "*", "react-native": "*", "react-native-aes-crypto": "*", "react-native-gesture-handler": "*", @@ -128,6 +158,24 @@ }, "react-native-vision-camera": { "optional": true + }, + "react-dom": { + "optional": true + }, + "clsx": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "framer-motion": { + "optional": true + }, + "react-hot-toast": { + "optional": true + }, + "react-textarea-autosize": { + "optional": true } }, "engines": { @@ -140,14 +188,16 @@ ], "moduleNameMapper": { "^@saigontechnology/firebase-chat-shared/rnProvider$": "/../../packages/shared/src/encryption/rnProvider.ts", - "^@saigontechnology/firebase-chat-shared$": "/../../packages/shared/src/index.ts" + "^@saigontechnology/firebase-chat-shared$": "/../../packages/shared/src/index.ts", + "\\.(css|less|scss|sass)$": "identity-obj-proxy" }, "transformIgnorePatterns": [ "/node_modules/(?!(react-native|@react-native|react-native-.*|@react-native-.*)/)", ".*/node_modules/.pnpm/[^/]*/node_modules/(?!(react-native|@react-native|react-native-.*|@react-native-.*)/)" ], "setupFilesAfterEnv": [ - "/jest.setup.cjs" + "/jest.setup.cjs", + "/src/setupTests.ts" ] }, "commitlint": { diff --git a/packages/rn-firebase-chat/postcss.config.cjs b/packages/rn-firebase-chat/postcss.config.cjs new file mode 100644 index 00000000..c9dbc3a7 --- /dev/null +++ b/packages/rn-firebase-chat/postcss.config.cjs @@ -0,0 +1,7 @@ +module.exports = { + plugins: [ + require('postcss-import'), + require('tailwindcss'), + require('autoprefixer'), + ], +} diff --git a/packages/rn-firebase-chat/src/setupTests.ts b/packages/rn-firebase-chat/src/setupTests.ts new file mode 100644 index 00000000..8f2609b7 --- /dev/null +++ b/packages/rn-firebase-chat/src/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; diff --git a/packages/rn-firebase-chat/src/web.ts b/packages/rn-firebase-chat/src/web.ts new file mode 100644 index 00000000..0ff32a15 --- /dev/null +++ b/packages/rn-firebase-chat/src/web.ts @@ -0,0 +1 @@ +export * from './web/index'; diff --git a/packages/rn-firebase-chat/src/web/addons/camera/CameraView.css b/packages/rn-firebase-chat/src/web/addons/camera/CameraView.css new file mode 100644 index 00000000..e94c9725 --- /dev/null +++ b/packages/rn-firebase-chat/src/web/addons/camera/CameraView.css @@ -0,0 +1,204 @@ +/* Camera View Styles */ +.camera-view { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: #000; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.camera-container { + position: relative; + width: 100%; + height: 100%; + background: #000; + display: flex; + align-items: center; + justify-content: center; +} + +.camera-preview { + width: 100%; + height: 100%; + object-fit: cover; +} + +.camera-controls { + position: absolute; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 20px; +} + +.control-button { + width: 60px; + height: 60px; + border-radius: 50%; + border: 2px solid #fff; + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.control-button:hover { + background: rgba(255, 255, 255, 0.3); + transform: scale(1.1); +} + +.control-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.control-button:disabled:hover { + transform: none; +} + +.capture-button { + width: 80px; + height: 80px; + background: #ff4444; + border-color: #ff4444; +} + +.capture-button.recording { + background: #dc3545; + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +} + +.capturing-overlay { + position: absolute; + top: 20px; + left: 20px; + background: rgba(0, 0, 0, 0.7); + padding: 8px 16px; + border-radius: 4px; + color: #fff; + font-size: 14px; + font-weight: 500; +} + +.recording-indicator { + display: flex; + align-items: center; + gap: 8px; +} + +.recording-dot { + width: 8px; + height: 8px; + background: #ff4444; + border-radius: 50%; + animation: blink 1s infinite; +} + +@keyframes blink { + + 0%, + 50% { + opacity: 1; + } + + 51%, + 100% { + opacity: 0; + } +} + +.camera-error { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + background: #1a1a1a; + color: #fff; +} + +.error-message { + text-align: center; + padding: 40px; + max-width: 400px; +} + +.error-message h3 { + margin: 0 0 16px 0; + font-size: 24px; + color: #ff4444; + font-weight: 600; +} + +.error-message p { + margin: 0 0 24px 0; + font-size: 16px; + line-height: 1.5; + color: #ccc; +} + +.close-button { + padding: 12px 24px; + background: #007bff; + color: white; + border: none; + border-radius: 6px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s ease; +} + +.close-button:hover { + background: #0056b3; +} + +/* Responsive styles */ +@media (max-width: 768px) { + .camera-controls { + bottom: 20px; + gap: 15px; + } + + .control-button { + width: 50px; + height: 50px; + font-size: 20px; + } + + .capture-button { + width: 70px; + height: 70px; + } + + .capturing-overlay { + top: 10px; + left: 10px; + padding: 6px 12px; + font-size: 12px; + } +} diff --git a/packages/rn-firebase-chat/src/addons/camera/CameraView.tsx b/packages/rn-firebase-chat/src/web/addons/camera/CameraView.tsx similarity index 97% rename from packages/rn-firebase-chat/src/addons/camera/CameraView.tsx rename to packages/rn-firebase-chat/src/web/addons/camera/CameraView.tsx index e979ca8b..3a944d6a 100644 --- a/packages/rn-firebase-chat/src/addons/camera/CameraView.tsx +++ b/packages/rn-firebase-chat/src/web/addons/camera/CameraView.tsx @@ -16,7 +16,7 @@ import { } from 'react-native'; import { Camera, useCameraDevice } from 'react-native-vision-camera'; import type { PhotoFile, VideoFile } from 'react-native-vision-camera'; -import { LazyVideo } from '../../chat/components/LazyVideo'; +import { LazyVideo } from '../../../chat/components/LazyVideo'; import { CaptureCameraButton } from './CaptureButton'; import type { CameraViewMethods, @@ -29,14 +29,14 @@ import { MessageTypes, type MessageProps, type MediaType, -} from '../../interfaces'; +} from '../../../interfaces'; import { generateRandomUUID, getAbsoluteFilePath, getMediaTypeFromExtension, -} from '../../utilities'; -import Images from '../../asset'; -import { useChatContext } from '../../hooks'; +} from '../../../utilities'; +import Images from '../../../asset'; +import { useChatContext } from '../../../hooks'; export type CameraViewProps = { onSend: (message: MessageProps) => void; diff --git a/packages/rn-firebase-chat/src/addons/camera/CaptureButton.tsx b/packages/rn-firebase-chat/src/web/addons/camera/CaptureButton.tsx similarity index 98% rename from packages/rn-firebase-chat/src/addons/camera/CaptureButton.tsx rename to packages/rn-firebase-chat/src/web/addons/camera/CaptureButton.tsx index d1824c3b..9e90ab1e 100644 --- a/packages/rn-firebase-chat/src/addons/camera/CaptureButton.tsx +++ b/packages/rn-firebase-chat/src/web/addons/camera/CaptureButton.tsx @@ -4,7 +4,7 @@ import { StyleSheet, TouchableOpacity, View } from 'react-native'; import type { Camera, PhotoFile, VideoFile } from 'react-native-vision-camera'; import type { OnOffType } from './interface'; import { CAPTURE_BUTTON_SIZE } from './constants'; -import { MessageTypes } from '../../interfaces'; +import { MessageTypes } from '../../../interfaces'; const BORDER_WIDTH = CAPTURE_BUTTON_SIZE * 0.1; diff --git a/packages/rn-firebase-chat/src/addons/camera/constants.ts b/packages/rn-firebase-chat/src/web/addons/camera/constants.ts similarity index 96% rename from packages/rn-firebase-chat/src/addons/camera/constants.ts rename to packages/rn-firebase-chat/src/web/addons/camera/constants.ts index 58a5eca4..9ba10d83 100644 --- a/packages/rn-firebase-chat/src/addons/camera/constants.ts +++ b/packages/rn-firebase-chat/src/web/addons/camera/constants.ts @@ -1,4 +1,4 @@ -import { MessageTypes } from '../../interfaces'; +import { MessageTypes } from '../../../interfaces'; export const initialMediaState = { type: MessageTypes.image, diff --git a/packages/rn-firebase-chat/src/addons/camera/enhancedHook.ts b/packages/rn-firebase-chat/src/web/addons/camera/enhancedHook.ts similarity index 99% rename from packages/rn-firebase-chat/src/addons/camera/enhancedHook.ts rename to packages/rn-firebase-chat/src/web/addons/camera/enhancedHook.ts index 0adc1bc1..fd1e9367 100644 --- a/packages/rn-firebase-chat/src/addons/camera/enhancedHook.ts +++ b/packages/rn-firebase-chat/src/web/addons/camera/enhancedHook.ts @@ -2,7 +2,7 @@ import { useCallback, useState, useRef } from 'react'; import { Keyboard, Alert, Vibration, Platform } from 'react-native'; import { cameraRef } from './CameraView'; -import { MessageTypes } from '../../interfaces'; +import { MessageTypes } from '../../../interfaces'; import { convertExtension, validateFileSize, diff --git a/packages/rn-firebase-chat/src/addons/camera/hook.ts b/packages/rn-firebase-chat/src/web/addons/camera/hook.ts similarity index 97% rename from packages/rn-firebase-chat/src/addons/camera/hook.ts rename to packages/rn-firebase-chat/src/web/addons/camera/hook.ts index 2f5cb1b8..bba28584 100644 --- a/packages/rn-firebase-chat/src/addons/camera/hook.ts +++ b/packages/rn-firebase-chat/src/web/addons/camera/hook.ts @@ -1,6 +1,6 @@ import { Keyboard } from 'react-native'; import { cameraRef } from './CameraView'; -import { MessageTypes } from '../../interfaces'; +import { MessageTypes } from '../../../interfaces'; import { convertExtension } from './utilities'; import { ImagePickerValue } from './interface'; diff --git a/packages/rn-firebase-chat/src/addons/camera/index.ts b/packages/rn-firebase-chat/src/web/addons/camera/index.ts similarity index 100% rename from packages/rn-firebase-chat/src/addons/camera/index.ts rename to packages/rn-firebase-chat/src/web/addons/camera/index.ts diff --git a/packages/rn-firebase-chat/src/addons/camera/interface.ts b/packages/rn-firebase-chat/src/web/addons/camera/interface.ts similarity index 97% rename from packages/rn-firebase-chat/src/addons/camera/interface.ts rename to packages/rn-firebase-chat/src/web/addons/camera/interface.ts index b7e8cc15..f0718aa6 100644 --- a/packages/rn-firebase-chat/src/addons/camera/interface.ts +++ b/packages/rn-firebase-chat/src/web/addons/camera/interface.ts @@ -1,6 +1,6 @@ import { ImageSourcePropType } from 'react-native'; -import type { ImagePickerValue } from '../../interfaces'; +import type { ImagePickerValue } from '../../../interfaces'; export type { ImagePickerValue }; export type OnOffType = 'off' | 'on'; diff --git a/packages/rn-firebase-chat/src/addons/camera/utilities.ts b/packages/rn-firebase-chat/src/web/addons/camera/utilities.ts similarity index 100% rename from packages/rn-firebase-chat/src/addons/camera/utilities.ts rename to packages/rn-firebase-chat/src/web/addons/camera/utilities.ts diff --git a/packages/rn-firebase-chat/src/web/addons/fileUpload/FileUploader.css b/packages/rn-firebase-chat/src/web/addons/fileUpload/FileUploader.css new file mode 100644 index 00000000..c9276956 --- /dev/null +++ b/packages/rn-firebase-chat/src/web/addons/fileUpload/FileUploader.css @@ -0,0 +1,211 @@ +/* File Uploader Styles */ +.file-uploader { + width: 100%; + border: 2px dashed #ccc; + border-radius: 8px; + background: #fafafa; + transition: all 0.2s ease; +} + +.file-uploader:hover { + border-color: #007bff; + background: #f0f8ff; +} + +.file-uploader.drag-over { + border-color: #007bff; + background: #e7f3ff; + transform: scale(1.02); +} + +.file-uploader.disabled { + opacity: 0.6; + pointer-events: none; + background: #f5f5f5; +} + +.upload-area { + padding: 40px 20px; + text-align: center; + cursor: pointer; + transition: background 0.2s ease; +} + +.upload-placeholder { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.upload-icon { + font-size: 48px; + opacity: 0.6; + margin-bottom: 8px; +} + +.upload-placeholder p { + margin: 0; + font-size: 16px; + font-weight: 500; + color: #333; +} + +.upload-placeholder small { + color: #666; + font-size: 14px; +} + +.upload-progress { + padding: 20px; + text-align: center; +} + +.progress-bar { + width: 100%; + height: 8px; + background: #e9ecef; + border-radius: 4px; + overflow: hidden; + margin-bottom: 12px; +} + +.progress-fill { + height: 100%; + background: #007bff; + border-radius: 4px; + transition: width 0.3s ease; +} + +.upload-progress p { + margin: 0; + font-size: 14px; + font-weight: 500; + color: #333; +} + +.selected-files { + padding: 16px; + border-top: 1px solid #e9ecef; + background: #fff; +} + +.selected-files h4 { + margin: 0 0 12px 0; + font-size: 14px; + font-weight: 600; + color: #333; +} + +.file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #f0f0f0; +} + +.file-item:last-child { + border-bottom: none; +} + +.file-name { + flex: 1; + font-size: 14px; + color: #333; + margin-right: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-size { + font-size: 12px; + color: #666; + margin-right: 8px; +} + +.remove-button { + width: 20px; + height: 20px; + border: none; + background: #dc3545; + color: white; + border-radius: 50%; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; +} + +.remove-button:hover { + background: #c82333; +} + +.remove-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.upload-button { + width: 100%; + padding: 12px; + background: #007bff; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s ease; +} + +.upload-button:hover { + background: #0056b3; +} + +.upload-button:disabled { + background: #6c757d; + cursor: not-allowed; +} + +.upload-error { + padding: 12px; + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + border-radius: 4px; + margin-top: 12px; + font-size: 14px; +} + +/* Responsive styles */ +@media (max-width: 768px) { + .upload-area { + padding: 30px 15px; + } + + .upload-icon { + font-size: 40px; + } + + .upload-placeholder p { + font-size: 14px; + } + + .upload-placeholder small { + font-size: 12px; + } + + .file-item { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .file-name { + margin-right: 0; + } +} \ No newline at end of file diff --git a/packages/rn-firebase-chat/src/web/addons/fileUpload/FileUploader.tsx b/packages/rn-firebase-chat/src/web/addons/fileUpload/FileUploader.tsx new file mode 100644 index 00000000..8fa59651 --- /dev/null +++ b/packages/rn-firebase-chat/src/web/addons/fileUpload/FileUploader.tsx @@ -0,0 +1,241 @@ +import React, { useRef, useState, useCallback } from 'react'; +import { useFileUpload } from './useFileUpload'; +import { FileUploaderProps } from './types'; +import './FileUploader.css'; + +export const FileUploader: React.FC = ({ + onFileSelect, + onUploadProgress, + onUploadComplete, + onError, + accept = '*/*', + multiple = false, + maxFiles = 5, + maxFileSize = 10 * 1024 * 1024, // 10MB + className = '', + disabled = false, + children, +}) => { + const fileInputRef = useRef(null); + const [isDragOver, setIsDragOver] = useState(false); + const [selectedFiles, setSelectedFiles] = useState([]); + + const { + uploadFiles, + uploading, + progress, + error: uploadError, + } = useFileUpload({ + onUploadProgress, + onUploadComplete: (url: string) => { + onUploadComplete?.([url]); + }, + onError, + maxFileSize, + }); + + const validateFiles = useCallback( + (files: FileList | File[]): File[] => { + const fileArray = Array.from(files); + + if (fileArray.length > maxFiles) { + onError?.(new Error(`Maximum ${maxFiles} files allowed`)); + return []; + } + + const validFiles = fileArray.filter((file) => { + if (file.size > maxFileSize) { + onError?.( + new Error( + `File "${file.name}" exceeds ${maxFileSize / (1024 * 1024)}MB limit` + ) + ); + return false; + } + return true; + }); + + return validFiles; + }, + [maxFiles, maxFileSize, onError] + ); + + const handleFileSelect = useCallback( + (files: File[]) => { + const validFiles = validateFiles(files); + if (validFiles.length > 0) { + setSelectedFiles(validFiles); + onFileSelect(validFiles); + } + }, + [validateFiles, onFileSelect] + ); + + const handleInputChange = useCallback( + (event: React.ChangeEvent) => { + const files = event.target.files; + if (files && files.length > 0) { + handleFileSelect(Array.from(files)); + } + }, + [handleFileSelect] + ); + + const handleDragEnter = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!disabled) { + setIsDragOver(true); + } + }, + [disabled] + ); + + const handleDragLeave = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragOver(false); + }, []); + + const handleDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, []); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragOver(false); + + if (disabled) return; + + const files = event.dataTransfer.files; + if (files && files.length > 0) { + handleFileSelect(Array.from(files)); + } + }, + [disabled, handleFileSelect] + ); + + const handleClick = useCallback(() => { + if (!disabled && fileInputRef.current) { + fileInputRef.current.click(); + } + }, [disabled]); + + const handleUpload = useCallback(async () => { + if (selectedFiles.length > 0) { + try { + const urls = await uploadFiles(selectedFiles); + onUploadComplete?.(urls); + setSelectedFiles([]); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } catch (error) { + onError?.(error as Error); + } + } + }, [selectedFiles, uploadFiles, onUploadComplete, onError]); + + const removeFile = useCallback( + (index: number) => { + const newFiles = selectedFiles.filter((_, i) => i !== index); + setSelectedFiles(newFiles); + onFileSelect(newFiles); + }, + [selectedFiles, onFileSelect] + ); + + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + return ( +
+ + + {children ? ( +
{children}
+ ) : ( +
+ {uploading ? ( +
+
+
+
+

Uploading... {Math.round(progress)}%

+
+ ) : ( +
+
📁
+

Click to select files or drag and drop

+ + Max {maxFiles} files, {formatFileSize(maxFileSize)} each + +
+ )} +
+ )} + + {/* Selected Files List */} + {selectedFiles.length > 0 && ( +
+

Selected Files:

+ {selectedFiles.map((file, index) => ( +
+ {file.name} + ({formatFileSize(file.size)}) + +
+ ))} + + {!uploading && ( + + )} +
+ )} + + {/* Error Display */} + {uploadError &&
{uploadError}
} +
+ ); +}; diff --git a/packages/rn-firebase-chat/src/web/addons/fileUpload/index.ts b/packages/rn-firebase-chat/src/web/addons/fileUpload/index.ts new file mode 100644 index 00000000..07253aba --- /dev/null +++ b/packages/rn-firebase-chat/src/web/addons/fileUpload/index.ts @@ -0,0 +1,9 @@ +export { FileUploader } from './FileUploader'; +export { useFileUpload } from './useFileUpload'; +export type { + FileUploaderProps, + UseFileUploadProps, + UseFileUploadReturn, + UploadedFile, + FileUploadOptions +} from './types'; diff --git a/packages/rn-firebase-chat/src/web/addons/fileUpload/types.ts b/packages/rn-firebase-chat/src/web/addons/fileUpload/types.ts new file mode 100644 index 00000000..3efb84c8 --- /dev/null +++ b/packages/rn-firebase-chat/src/web/addons/fileUpload/types.ts @@ -0,0 +1,43 @@ +export interface FileUploaderProps { + onFileSelect: (files: File[]) => void; + onUploadProgress?: (progress: number) => void; + onUploadComplete?: (urls: string[]) => void; + onError?: (error: Error) => void; + accept?: string; + multiple?: boolean; + maxFiles?: number; + maxFileSize?: number; // in bytes + className?: string; + disabled?: boolean; + children?: React.ReactNode; +} + +export interface UseFileUploadProps { + onUploadProgress?: (progress: number) => void; + onUploadComplete?: (url: string) => void; + onError?: (error: Error) => void; + maxFileSize?: number; + allowedTypes?: string[]; + storagePath?: string; +} + +export interface UseFileUploadReturn { + uploadFile: (file: File) => Promise; + uploadFiles: (files: File[]) => Promise; + uploading: boolean; + progress: number; + error: string | null; +} + +export interface UploadedFile { + file: File; + url: string; + uploadedAt: Date; +} + +export interface FileUploadOptions { + compress?: boolean; + quality?: number; // 0-1 for image compression + maxWidth?: number; + maxHeight?: number; +} diff --git a/packages/rn-firebase-chat/src/web/addons/fileUpload/useFileUpload.ts b/packages/rn-firebase-chat/src/web/addons/fileUpload/useFileUpload.ts new file mode 100644 index 00000000..17a6b75c --- /dev/null +++ b/packages/rn-firebase-chat/src/web/addons/fileUpload/useFileUpload.ts @@ -0,0 +1,169 @@ +import { useState, useCallback } from 'react'; +import { ref, uploadBytesResumable, getDownloadURL } from 'firebase/storage'; +import { getFirebaseStorage } from '../../../services/firebase'; +import { UseFileUploadProps, UseFileUploadReturn, FileUploadOptions } from './types'; + +export const useFileUpload = ({ + onUploadProgress, + onUploadComplete, + onError, + maxFileSize = 10 * 1024 * 1024, // 10MB default + allowedTypes = ['image/*', 'video/*', 'application/pdf', '.doc', '.docx', '.txt'], + storagePath = 'chat-files', +}: UseFileUploadProps = {}): UseFileUploadReturn => { + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + + const storage = getFirebaseStorage(); + + const validateFile = useCallback((file: File): boolean => { + // Check file size + if (file.size > maxFileSize) { + setError(`File size exceeds ${maxFileSize / (1024 * 1024)}MB limit`); + return false; + } + + // Check file type + const isAllowed = allowedTypes.some(type => { + if (type.includes('*')) { + const baseType = type.split('/')[0]; + return file.type.startsWith(baseType); + } + return file.type === type || file.name.toLowerCase().endsWith(type); + }); + + if (!isAllowed) { + setError(`File type not allowed. Supported types: ${allowedTypes.join(', ')}`); + return false; + } + + return true; + }, [maxFileSize, allowedTypes]); + + const compressImage = useCallback((file: File, options: FileUploadOptions): Promise => { + return new Promise((resolve) => { + if (!file.type.startsWith('image/') || !options.compress) { + resolve(file); + return; + } + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = new Image(); + + img.onload = () => { + const { maxWidth = 1920, maxHeight = 1080, quality = 0.8 } = options; + + let { width, height } = img; + + // Calculate new dimensions + if (width > maxWidth || height > maxHeight) { + const ratio = Math.min(maxWidth / width, maxHeight / height); + width = width * ratio; + height = height * ratio; + } + + canvas.width = width; + canvas.height = height; + + ctx?.drawImage(img, 0, 0, width, height); + + canvas.toBlob((blob) => { + if (blob) { + const compressedFile = new File([blob], file.name, { + type: file.type, + lastModified: Date.now(), + }); + resolve(compressedFile); + } else { + resolve(file); + } + }, file.type, quality); + }; + + img.src = URL.createObjectURL(file); + }); + }, []); + + const uploadFile = useCallback(async ( + file: File, + options: FileUploadOptions = {} + ): Promise => { + try { + setError(null); + setUploading(true); + setProgress(0); + + if (!validateFile(file)) { + throw new Error('File validation failed'); + } + + // Compress image if needed + const processedFile = await compressImage(file, options); + + // Create unique filename + const timestamp = Date.now(); + const randomString = Math.random().toString(36).substring(2); + const fileName = `${timestamp}-${randomString}-${processedFile.name}`; + + const fileRef = ref(storage, `${storagePath}/${fileName}`); + const uploadTask = uploadBytesResumable(fileRef, processedFile); + + return new Promise((resolve, reject) => { + uploadTask.on( + 'state_changed', + (snapshot) => { + const progressValue = (snapshot.bytesTransferred / snapshot.totalBytes) * 100; + setProgress(progressValue); + onUploadProgress?.(progressValue); + }, + (error) => { + setUploading(false); + setProgress(0); + const errorMessage = `Upload failed: ${error.message}`; + setError(errorMessage); + onError?.(new Error(errorMessage)); + reject(error); + }, + async () => { + try { + const downloadURL = await getDownloadURL(uploadTask.snapshot.ref); + setUploading(false); + setProgress(100); + onUploadComplete?.(downloadURL); + resolve(downloadURL); + } catch (error) { + setUploading(false); + setProgress(0); + const errorMessage = 'Failed to get download URL'; + setError(errorMessage); + onError?.(new Error(errorMessage)); + reject(error); + } + } + ); + }); + } catch (error) { + setUploading(false); + setProgress(0); + const errorMessage = error instanceof Error ? error.message : 'Upload failed'; + setError(errorMessage); + onError?.(new Error(errorMessage)); + throw error; + } + }, [storage, storagePath, validateFile, compressImage, onUploadProgress, onUploadComplete, onError]); + + const uploadFiles = useCallback(async (files: File[]): Promise => { + const uploadPromises = files.map(file => uploadFile(file)); + return Promise.all(uploadPromises); + }, [uploadFile]); + + return { + uploadFile, + uploadFiles, + uploading, + progress, + error, + }; +}; diff --git a/packages/rn-firebase-chat/src/web/addons/gallery/GalleryView.css b/packages/rn-firebase-chat/src/web/addons/gallery/GalleryView.css new file mode 100644 index 00000000..8223eef4 --- /dev/null +++ b/packages/rn-firebase-chat/src/web/addons/gallery/GalleryView.css @@ -0,0 +1,177 @@ +/* Gallery View Styles */ +.gallery-view { + padding: 16px; + background: #f8f9fa; + border-radius: 8px; +} + +.gallery-view.empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; +} + +.empty-state { + text-align: center; + color: #6c757d; +} + +.empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.empty-state p { + margin: 0; + font-size: 16px; +} + +.gallery-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); +} + +.gallery-item { + position: relative; + aspect-ratio: 1; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + background: #f5f5f5; + transition: transform 0.2s ease; +} + +.gallery-item:hover { + transform: scale(1.05); +} + +.media-thumbnail { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 8px; +} + +.video-thumbnail { + position: relative; + width: 100%; + height: 100%; +} + +.play-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.3); + border-radius: 8px; +} + +.play-button { + font-size: 24px; + color: white; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); +} + +.audio-thumbnail, +.file-thumbnail { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8px; + text-align: center; + height: 100%; + background: #e9ecef; +} + +.audio-icon, +.file-icon { + font-size: 32px; + margin-bottom: 8px; +} + +.audio-thumbnail p, +.file-thumbnail p { + margin: 0 0 4px 0; + font-size: 12px; + font-weight: 500; + word-break: break-word; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.audio-thumbnail small, +.file-thumbnail small { + font-size: 10px; + color: #6c757d; +} + +.delete-button { + position: absolute; + top: 4px; + right: 4px; + width: 24px; + height: 24px; + border: none; + background: rgba(220, 53, 69, 0.9); + color: white; + border-radius: 50%; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; +} + +.gallery-item:hover .delete-button { + opacity: 1; +} + +.delete-button:hover { + background: #dc3545; +} + +.gallery-footer { + margin-top: 16px; + text-align: center; + color: #6c757d; + font-size: 14px; +} + +.gallery-footer p { + margin: 0; +} + +/* Responsive styles */ +@media (max-width: 768px) { + .gallery-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 12px; + } + + .gallery-view { + padding: 12px; + } + + .audio-thumbnail p, + .file-thumbnail p { + font-size: 11px; + } + + .audio-icon, + .file-icon { + font-size: 28px; + } +} \ No newline at end of file diff --git a/packages/rn-firebase-chat/src/web/addons/gallery/GalleryView.tsx b/packages/rn-firebase-chat/src/web/addons/gallery/GalleryView.tsx new file mode 100644 index 00000000..ef0daec8 --- /dev/null +++ b/packages/rn-firebase-chat/src/web/addons/gallery/GalleryView.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { useGallery } from './useGallery'; +import { GalleryViewProps } from './types'; +import './GalleryView.css'; + +export const GalleryView: React.FC = ({ + files, + onFileSelect, + onFileDelete, + className = '', + maxItems = 20, + gridColumns = 4, + showDeleteButton = true, +}) => { + const { openViewer, deleteFile } = useGallery({ + files, + onFileSelect, + onFileDelete, + }); + + const displayFiles = files.slice(0, maxItems); + + const getFileIcon = (type: string) => { + switch (type) { + case 'image': + return '🖼️'; + case 'video': + return '🎥'; + case 'audio': + return '🎵'; + case 'document': + return '📄'; + default: + return '📎'; + } + }; + + const handleFileClick = (file: any) => { + openViewer(file); + onFileSelect?.(file); + }; + + const handleDelete = (e: React.MouseEvent, fileId: string) => { + e.stopPropagation(); + if (window.confirm('Are you sure you want to delete this file?')) { + deleteFile(fileId); + } + }; + + const formatFileSize = (bytes?: number): string => { + if (!bytes) return ''; + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + if (files.length === 0) { + return ( +
+
+
📁
+

No files to display

+
+
+ ); + } + + return ( +
+
+ {displayFiles.map((file) => ( +
handleFileClick(file)} + > + {file.type === 'image' ? ( + {file.name} + ) : file.type === 'video' ? ( +
+
+ ) : file.type === 'audio' ? ( +
+
🎵
+

{file.name}

+ {formatFileSize(file.size)} +
+ ) : ( +
+
{getFileIcon(file.type)}
+

{file.name}

+ {formatFileSize(file.size)} +
+ )} + + {showDeleteButton && ( + + )} +
+ ))} +
+ + {files.length > maxItems && ( +
+

Showing {maxItems} of {files.length} files

+
+ )} +
+ ); +}; diff --git a/packages/rn-firebase-chat/src/web/addons/gallery/MediaViewer.css b/packages/rn-firebase-chat/src/web/addons/gallery/MediaViewer.css new file mode 100644 index 00000000..02da456a --- /dev/null +++ b/packages/rn-firebase-chat/src/web/addons/gallery/MediaViewer.css @@ -0,0 +1,210 @@ +/* Media Viewer Styles */ +.media-viewer { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.9); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; +} + +.media-viewer-content { + position: relative; + max-width: 90vw; + max-height: 90vh; + border-radius: 8px; + overflow: hidden; +} + +.media-content { + max-width: 100%; + max-height: 90vh; + object-fit: contain; + border-radius: 8px; +} + +.media-viewer-controls { + position: absolute; + top: 20px; + right: 20px; + display: flex; + gap: 12px; +} + +.viewer-button { + width: 44px; + height: 44px; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + color: white; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; + backdrop-filter: blur(4px); +} + +.viewer-button:hover { + background: rgba(255, 255, 255, 0.3); +} + +.close-button { + background: rgba(220, 53, 69, 0.8); +} + +.close-button:hover { + background: rgba(220, 53, 69, 1); +} + +.nav-button { + background: rgba(0, 123, 255, 0.8); +} + +.nav-button:hover { + background: rgba(0, 123, 255, 1); +} + +.download-button { + background: rgba(40, 167, 69, 0.8); + text-decoration: none; + color: white; +} + +.download-button:hover { + background: rgba(40, 167, 69, 1); +} + +.media-info { + position: absolute; + bottom: 20px; + left: 20px; + right: 20px; + background: rgba(0, 0, 0, 0.7); + color: white; + padding: 16px; + border-radius: 8px; + backdrop-filter: blur(4px); +} + +.media-info h3 { + margin: 0 0 8px 0; + font-size: 18px; + font-weight: 600; + word-break: break-word; +} + +.media-info p { + margin: 0; + font-size: 14px; + color: rgba(255, 255, 255, 0.8); +} + +/* Audio Player Styles */ +.audio-player { + background: #2a2a2a; + color: white; + padding: 40px; + border-radius: 8px; + text-align: center; + min-width: 300px; +} + +.audio-info h3 { + margin: 0 0 8px 0; + font-size: 20px; + color: white; +} + +.audio-info p { + margin: 0 0 20px 0; + color: #ccc; +} + +.audio-player audio { + width: 100%; + margin-top: 20px; +} + +/* Document Viewer Styles */ +.document-viewer { + background: #2a2a2a; + color: white; + padding: 40px; + border-radius: 8px; + text-align: center; + min-width: 300px; +} + +.document-info h3 { + margin: 0 0 8px 0; + font-size: 20px; + color: white; +} + +.document-info p { + margin: 0 0 20px 0; + color: #ccc; +} + +.download-link { + display: inline-block; + padding: 12px 24px; + background: #007bff; + color: white; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + transition: background 0.2s ease; +} + +.download-link:hover { + background: #0056b3; +} + +/* Responsive styles */ +@media (max-width: 768px) { + .media-viewer-content { + max-width: 95vw; + max-height: 95vh; + } + + .media-viewer-controls { + top: 10px; + right: 10px; + gap: 8px; + } + + .viewer-button { + width: 36px; + height: 36px; + font-size: 16px; + } + + .media-info { + bottom: 10px; + left: 10px; + right: 10px; + padding: 12px; + } + + .media-info h3 { + font-size: 16px; + } + + .media-info p { + font-size: 12px; + } + + .audio-player, + .document-viewer { + padding: 30px 20px; + min-width: 250px; + } +} \ No newline at end of file diff --git a/packages/rn-firebase-chat/src/web/addons/gallery/MediaViewer.tsx b/packages/rn-firebase-chat/src/web/addons/gallery/MediaViewer.tsx new file mode 100644 index 00000000..f25f1a80 --- /dev/null +++ b/packages/rn-firebase-chat/src/web/addons/gallery/MediaViewer.tsx @@ -0,0 +1,165 @@ +import React, { useEffect } from 'react'; +import { MediaViewerProps } from './types'; +import './MediaViewer.css'; + +export const MediaViewer: React.FC = ({ + file, + isOpen, + onClose, + onNext, + onPrevious, + className = '', +}) => { + // Handle keyboard navigation + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case 'Escape': + onClose(); + break; + case 'ArrowLeft': + onPrevious?.(); + break; + case 'ArrowRight': + onNext?.(); + break; + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose, onNext, onPrevious]); + + // Prevent body scroll when viewer is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'auto'; + } + + return () => { + document.body.style.overflow = 'auto'; + }; + }, [isOpen]); + + if (!isOpen || !file) return null; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + const renderMediaContent = () => { + switch (file.type) { + case 'image': + return ( + {file.name} + ); + + case 'video': + return ( +
diff --git a/packages/rn-firebase-chat/src/web/addons/gallery/MediaViewer.tsx b/packages/rn-firebase-chat/src/web/addons/gallery/MediaViewer.tsx index f25f1a80..523c6ab5 100644 --- a/packages/rn-firebase-chat/src/web/addons/gallery/MediaViewer.tsx +++ b/packages/rn-firebase-chat/src/web/addons/gallery/MediaViewer.tsx @@ -56,22 +56,11 @@ export const MediaViewer: React.FC = ({ const renderMediaContent = () => { switch (file.type) { case 'image': - return ( - {file.name} - ); + return {file.name}; case 'video': return ( -