├── test-results
└── .last-run.json
├── public
├── robots.txt
├── favicon.ico
├── audio
│ └── notify.mp3
├── images
│ ├── screen.png
│ ├── YoursIcon.svg
│ ├── retrofeed_512_2.svg
│ └── logo-noBgColor.svg
└── manifest.json
├── babel-plugin-macros.config.cjs
├── .dockerignore
├── .playwright-mcp
├── login-page-blocked.png
└── sigma-auth-backup-2025-10-28.json
├── bigblocks.config.json
├── railway.toml
├── postcss.config.js
├── src
├── types
│ ├── bops.d.ts
│ ├── global.d.ts
│ └── index.ts
├── lib
│ └── utils.ts
├── utils
│ ├── strings.ts
│ ├── common.ts
│ ├── validation.ts
│ ├── sign.js
│ ├── storage.ts
│ ├── next-polyfill.js
│ ├── backupDecryptor.ts
│ ├── file.jsx
│ └── backupDetector.ts
├── hooks
│ ├── useActiveUser.ts
│ ├── use-mobile.ts
│ ├── index.ts
│ └── useOwnedThemes.tsx
├── components
│ ├── authForm
│ │ ├── SignupPage.tsx
│ │ ├── Form.js
│ │ ├── Label.ts
│ │ ├── Input.ts
│ │ ├── SubmitButton.js
│ │ ├── Select.js
│ │ ├── Layout.tsx
│ │ └── BigBlocksLoginPage.tsx.disabled
│ ├── ui
│ │ ├── skeleton.tsx
│ │ ├── label.tsx
│ │ ├── textarea.tsx
│ │ ├── separator.tsx
│ │ ├── input.tsx
│ │ ├── switch.tsx
│ │ ├── badge.tsx
│ │ ├── avatar.tsx
│ │ ├── scroll-area.tsx
│ │ ├── alert.tsx
│ │ ├── Tooltip.tsx
│ │ ├── List.jsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── EmojiPicker.tsx
│ │ └── dialog.tsx
│ ├── dashboard
│ │ ├── InvisibleSubmitButton.jsx
│ │ ├── ChatArea.tsx
│ │ ├── ChannelTextArea.js
│ │ ├── List.jsx
│ │ ├── Hashtag.jsx
│ │ ├── At.jsx
│ │ ├── ArrowTooltip.jsx
│ │ ├── modals
│ │ │ ├── FilePreviewModal.tsx
│ │ │ ├── PinChannelModal.tsx
│ │ │ └── DirectMessageModal.tsx
│ │ ├── SubmitButton.tsx
│ │ ├── MessageFiles.tsx
│ │ ├── Dashboard.tsx
│ │ ├── MessageFile.tsx
│ │ ├── FileRenderer.tsx
│ │ ├── ProfilePanel.tsx
│ │ ├── Sidebar.tsx
│ │ ├── UserPanel.tsx
│ │ ├── ListItem.jsx
│ │ ├── ChannelList.tsx
│ │ ├── ServerList.tsx
│ │ ├── MemberList.tsx
│ │ ├── Messages.tsx
│ │ └── Avatar.tsx
│ ├── home
│ │ ├── HomePage.jsx
│ │ └── Layout.jsx
│ ├── common
│ │ └── ConfirmationModal.tsx
│ ├── icons
│ │ ├── HandcashIcon.jsx
│ │ └── YoursIcon.tsx
│ ├── login-form.tsx
│ └── ErrorBoundary.tsx
├── twin.d.ts
├── reducers
│ ├── sidebarReducer.ts
│ ├── profileReducer.ts
│ ├── serverReducer.ts
│ └── settingsReducer.ts
├── App.tsx
├── config
│ └── constants.ts
├── context
│ ├── bmap
│ │ └── index.tsx
│ └── theme.tsx
├── constants
│ └── servers.ts
├── styles
│ ├── GlobalStyles.tsx
│ └── typography.ts
├── store.ts
├── main.tsx
├── design
│ └── mixins.ts
├── routes
│ ├── index.tsx
│ └── lazyComponents.tsx
└── api
│ ├── channel.ts
│ └── fetch.ts
├── .bitcoin
└── templates
│ └── p2pkh.json
├── tsconfig.node.json
├── .mcp.json
├── Caddyfile
├── index.html
├── components.json
├── tsconfig.json
├── Dockerfile
├── test-oauth-manual.js
├── .vscode
└── settings.json
├── playwright.config.ts
├── biome.json
├── tests
├── e2e
│ ├── homepage.spec.ts
│ ├── styling.spec.ts
│ └── unhooked-features.spec.ts
└── visual
│ ├── README.md
│ └── playwright.config.ts
├── README.md
├── LICENSE
├── .gitignore
├── .cursorrules
├── BACKEND_FIX_VALIDATION.md
├── vite.config.ts
├── .claude
└── commands
│ └── review.md
├── MESSAGE_FORMAT.md
├── API_STRUCTURE.md
└── scripts
└── validate-build.ts
/test-results/.last-run.json:
--------------------------------------------------------------------------------
1 | {
2 | "status": "passed",
3 | "failedTests": []
4 | }
5 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rohenaz/allaboard-bitchat-nitro/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/babel-plugin-macros.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | twin: {
3 | preset: 'emotion',
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/public/audio/notify.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rohenaz/allaboard-bitchat-nitro/HEAD/public/audio/notify.mp3
--------------------------------------------------------------------------------
/public/images/screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rohenaz/allaboard-bitchat-nitro/HEAD/public/images/screen.png
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .git
3 | .env
4 | *.md
5 | .gitignore
6 | .vscode
7 | # Ensure build directory is included
8 | !build/
--------------------------------------------------------------------------------
/.playwright-mcp/login-page-blocked.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rohenaz/allaboard-bitchat-nitro/HEAD/.playwright-mcp/login-page-blocked.png
--------------------------------------------------------------------------------
/bigblocks.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "componentsDir": "./components/bigblocks",
3 | "typescript": true,
4 | "radixThemes": false,
5 | "initialized": true
6 | }
7 |
--------------------------------------------------------------------------------
/railway.toml:
--------------------------------------------------------------------------------
1 | "$schema" = "https://railway.com/railway.schema.json"
2 |
3 | [build]
4 | builder = "DOCKERFILE"
5 |
6 | [deploy]
7 | restartPolicyType = "ON_FAILURE"
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | '@tailwindcss/postcss': {},
5 | },
6 | };
7 | export default config;
8 |
--------------------------------------------------------------------------------
/src/types/bops.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'bops' {
2 | function from(str: string, encoding: string): Buffer;
3 | function to(buffer: Buffer, encoding: string): string;
4 | export { from, to };
5 | }
6 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/strings.ts:
--------------------------------------------------------------------------------
1 | // Email validation functions moved to utils/validation.ts
2 | // Import from there: import { isValidEmail, validateEmail } from '../utils/validation';
3 |
4 | export { isValidEmail } from './validation';
5 |
--------------------------------------------------------------------------------
/.bitcoin/templates/p2pkh.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "P2PKH",
3 | "pattern": "OP_DUP OP_HASH160 ([0-9A-Fa-f]{40}) OP_EQUALVERIFY OP_CHECKSIG",
4 | "description": "Pay to Public Key Hash - Standard Bitcoin payment script",
5 | "placeholders": ["pubKeyHash"]
6 | }
7 |
--------------------------------------------------------------------------------
/src/hooks/useActiveUser.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import type { RootState } from '../store';
3 |
4 | export const useActiveUser = () => {
5 | const session = useSelector((state: RootState) => state.session);
6 | return session.user;
7 | };
8 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/.playwright-mcp/sigma-auth-backup-2025-10-28.json:
--------------------------------------------------------------------------------
1 | kJLMw46zowN9wVnwL3AWVtseATSxDVlq085fKPkECx2XBCFfIWcETfXwLKsgM6f3zxEzMu+BI2jI+I6l9930vhI8dzonkPdTE72jP1uqmktyLol8pD2fQ74wpMzYiTzt+awcqx92L8CmuvtnBHm9XoEGtTh+cytNCnDLZLiqraIlrthUIfEecFWSJ7wOH87mO/BmMbL+GKFyAB8aj6N0nhyG9rzUetJ3hRd1xPEn25ol
--------------------------------------------------------------------------------
/src/components/authForm/SignupPage.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 |
3 | export const SignupPage: FC = () => {
4 | return (
5 |
6 |
Sign Up
7 | {/* Add signup form here */}
8 |
9 | );
10 | };
11 |
12 | export default SignupPage;
13 |
--------------------------------------------------------------------------------
/src/components/authForm/Form.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Form = styled.form`
4 | display: flex;
5 | flex-direction: column;
6 | font-size: 13px;
7 | padding: 12px 0;
8 | width: 400px;
9 |
10 | @media (max-width: 550px) {
11 | width: 100%;
12 | }
13 | `;
14 |
15 | export default Form;
16 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 | return (
5 |
10 | )
11 | }
12 |
13 | export { Skeleton }
14 |
--------------------------------------------------------------------------------
/src/components/dashboard/InvisibleSubmitButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from 'styled-components';
4 |
5 | export const Wrapper = styled.button.attrs((_p) => ({ type: 'submit' }))`
6 | display: none;
7 | `;
8 |
9 | const InvisibleSubmitButton = () => {
10 | return ;
11 | };
12 |
13 | export default InvisibleSubmitButton;
14 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Discord",
3 | "name": "Discord Clone",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/dashboard/ChatArea.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import Messages from './Messages';
3 | import WriteArea from './WriteArea';
4 |
5 | const ChatArea: React.FC = () => {
6 | return (
7 |
8 |
9 |
10 |
11 | );
12 | };
13 |
14 | export default ChatArea;
15 |
--------------------------------------------------------------------------------
/src/components/dashboard/ChannelTextArea.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const ChannelTextArea = styled.input.attrs({
4 | className: 'border-0 rounded-lg text-[15px] h-11 w-full outline-hidden',
5 | })`
6 | background-color: var(--input);
7 | color: var(--foreground);
8 |
9 | &::placeholder {
10 | color: var(--muted-foreground);
11 | }
12 | `;
13 |
14 | export default ChannelTextArea;
15 |
--------------------------------------------------------------------------------
/.mcp.json:
--------------------------------------------------------------------------------
1 | {
2 | "mcpServers": {
3 | "next-devtools": {
4 | "command": "bunx",
5 | "args": ["next-devtools-mcp@latest"]
6 | },
7 | "encore-mcp": {
8 | "command": "encore",
9 | "args": ["mcp", "run", "--app=go-faucet-api-mazi"],
10 | "cwd": "../go-faucet-api"
11 | },
12 | "playwright": {
13 | "command": "bunx",
14 | "args": ["@playwright/mcp@latest"]
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/twin.d.ts:
--------------------------------------------------------------------------------
1 | import 'twin.macro';
2 | import type { css as cssImport } from '@emotion/react';
3 | import type { CSSInterpolation } from '@emotion/serialize';
4 | import type styledImport from '@emotion/styled';
5 |
6 | declare module 'twin.macro' {
7 | const styled: typeof styledImport;
8 | const css: typeof cssImport;
9 | }
10 |
11 | declare module 'react' {
12 | interface DOMAttributes<_T> {
13 | tw?: string;
14 | css?: CSSInterpolation;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/utils/common.ts:
--------------------------------------------------------------------------------
1 | export enum FetchStatus {
2 | Idle = 'idle',
3 | Loading = 'loading',
4 | Success = 'success',
5 | Error = 'error',
6 | }
7 |
8 | export const defaultAlias = {
9 | '@context': 'https://schema.org',
10 | '@type': 'Person',
11 | alternateName: '',
12 | logo: '',
13 | image: '',
14 | homeLocation: {
15 | '@type': 'Place',
16 | name: '',
17 | },
18 | description: '',
19 | url: '',
20 | paymail: '',
21 | bitcoinAddress: '',
22 | };
23 |
--------------------------------------------------------------------------------
/Caddyfile:
--------------------------------------------------------------------------------
1 | :{$PORT}
2 |
3 | root * /srv
4 | encode gzip
5 |
6 | # Set proper MIME types for SVG files
7 | @svg {
8 | path *.svg
9 | }
10 | route @svg {
11 | header Content-Type image/svg+xml
12 | file_server
13 | }
14 |
15 | # Handle static assets first
16 | @static {
17 | path /images/* /audio/* /js/* /assets/*
18 | }
19 | route @static {
20 | file_server
21 | }
22 |
23 | # Handle all other routes with SPA fallback
24 | file_server
25 | try_files {path} /index.html
26 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | BitChat Nitro
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/components/authForm/Label.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | interface LabelProps {
4 | error?: boolean;
5 | }
6 |
7 | const Label = styled.label`
8 | color: ${(p) => (p.error ? 'var(--text-danger)' : 'var(--foreground)')};
9 | font-weight: 500;
10 | text-transform: uppercase;
11 | font-size: 12px;
12 | padding: 8px 0;
13 | display: flex;
14 | align-items: center;
15 | cursor: pointer;
16 | `;
17 |
18 | export default Label;
19 |
--------------------------------------------------------------------------------
/src/components/dashboard/List.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from 'styled-components';
4 |
5 | const Wrapper = styled.div`
6 | display: flex;
7 | flex-direction: ${(p) => (p.horizontal ? 'row' : 'column')};
8 | gap: ${(p) => p.gap};
9 | `;
10 |
11 | const List = ({ horizontal, gap, children, ...delegated }) => {
12 | return (
13 |
14 | {children}
15 |
16 | );
17 | };
18 |
19 | export default List;
20 |
--------------------------------------------------------------------------------
/src/reducers/sidebarReducer.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 |
3 | interface SidebarState {
4 | isOpen: boolean;
5 | }
6 |
7 | const initialState: SidebarState = {
8 | isOpen: false,
9 | };
10 |
11 | const sidebarSlice = createSlice({
12 | name: 'sidebar',
13 | initialState,
14 | reducers: {
15 | toggleSidebar: (state) => {
16 | state.isOpen = !state.isOpen;
17 | },
18 | },
19 | });
20 |
21 | export const { toggleSidebar } = sidebarSlice.actions;
22 |
23 | export default sidebarSlice.reducer;
24 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | declare global {
4 | interface Window {
5 | location: Location;
6 | }
7 |
8 | interface HTMLFormElement extends Element {
9 | elements: HTMLFormControlsCollection;
10 | requestSubmit(): void;
11 | }
12 |
13 | interface HTMLTextAreaElement extends HTMLElement {
14 | value: string;
15 | form: HTMLFormElement | null;
16 | }
17 |
18 | interface HTMLFormControlsCollection {
19 | namedItem(name: string): HTMLElement | null;
20 | }
21 | }
22 |
23 | export {};
24 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/index.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "iconLibrary": "lucide",
14 | "aliases": {
15 | "components": "@/components",
16 | "utils": "@/lib/utils",
17 | "ui": "@/components/ui",
18 | "lib": "@/lib",
19 | "hooks": "@/hooks"
20 | },
21 | "registries": {}
22 | }
23 |
--------------------------------------------------------------------------------
/src/utils/validation.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Validates an email string against a comprehensive regex pattern
3 | */
4 | const validateEmail = (email: string): RegExpMatchArray | null => {
5 | return String(email)
6 | .toLowerCase()
7 | .match(
8 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
9 | );
10 | };
11 |
12 | /**
13 | * Returns true if the candidate string is a valid email address
14 | */
15 | export const isValidEmail = (candidate: string): boolean => Boolean(validateEmail(candidate));
16 |
--------------------------------------------------------------------------------
/src/components/dashboard/Hashtag.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { FaHashtag } from 'react-icons/fa';
4 | import styled from 'styled-components';
5 |
6 | import { baseIcon, roundedBackground } from '../../design/mixins';
7 |
8 | export const Wrapper = styled.div`
9 | ${baseIcon};
10 | color: ${(p) => p.color || 'var(--muted-foreground)'};
11 | ${(p) => p.bgColor && roundedBackground};
12 | `;
13 |
14 | const Hashtag = ({ ...delegated }) => {
15 | return (
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default Hashtag;
23 |
--------------------------------------------------------------------------------
/src/hooks/use-mobile.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | const MOBILE_BREAKPOINT = 768;
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(undefined);
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
12 | };
13 | mql.addEventListener('change', onChange);
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
15 | return () => mql.removeEventListener('change', onChange);
16 | }, []);
17 |
18 | return !!isMobile;
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/dashboard/At.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { FaAt } from 'react-icons/fa';
4 | import styled from 'styled-components';
5 |
6 | import { baseIcon, roundedBackground } from '../../design/mixins';
7 |
8 | export const Wrapper = styled.div`
9 | display: flex;
10 | align-items: center;
11 | justify-content: center;
12 | ${baseIcon};
13 | color: ${(p) => p.color || 'var(--muted-foreground)'};
14 | ${(p) => p.bgColor && roundedBackground};
15 | `;
16 |
17 | const At = ({ ...delegated }) => {
18 | return (
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default At;
26 |
--------------------------------------------------------------------------------
/src/reducers/profileReducer.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 |
3 | interface ProfileState {
4 | profile: unknown; // TODO: Add proper profile type
5 | isOpen: boolean;
6 | }
7 |
8 | const initialState: ProfileState = {
9 | profile: {},
10 | isOpen: false,
11 | };
12 |
13 | const profileSlice = createSlice({
14 | name: 'profile',
15 | initialState,
16 | reducers: {
17 | setProfile: (state, action) => {
18 | state.profile = action.payload;
19 | },
20 | toggleProfile: (state) => {
21 | state.isOpen = !state.isOpen;
22 | },
23 | },
24 | });
25 |
26 | export const { setProfile, toggleProfile } = profileSlice.actions;
27 |
28 | export default profileSlice.reducer;
29 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | function Label({
7 | className,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
19 | )
20 | }
21 |
22 | export { Label }
23 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from 'react';
2 | import { RouterProvider } from 'react-router-dom';
3 | import ErrorBoundary from './components/ErrorBoundary';
4 | import router from './routes/index.tsx';
5 | import { GlobalStyles } from './styles/GlobalStyles';
6 |
7 | const LoadingSpinner = () => (
8 | Loading...
9 | );
10 |
11 | const App = () => {
12 | return (
13 |
14 |
15 |
16 | }>
17 |
18 |
19 |
20 |
21 | );
22 | };
23 |
24 | export default App;
25 |
--------------------------------------------------------------------------------
/src/components/dashboard/ArrowTooltip.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Tooltip from '@mui/material/Tooltip';
4 | import { styled } from '@mui/material/styles';
5 |
6 | const Wrapper = styled((p) => (
7 |
8 | ))`
9 | & .MuiTooltip-tooltip {
10 | background-color: var(--popover);
11 | color: var(--popover-foreground);
12 | font-size: 14px;
13 | padding: 10px;
14 | }
15 |
16 | & .MuiTooltip-arrow {
17 | color: var(--popover);
18 | }
19 | `;
20 |
21 | const ArrowTooltip = ({ children, ...delegated }) => {
22 | return (
23 |
24 | {children}
25 |
26 | );
27 | };
28 |
29 | export default ArrowTooltip;
30 |
--------------------------------------------------------------------------------
/src/utils/sign.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Get a signing path from a hex number
3 | *
4 | * @param hexString {string}
5 | * @param hardened {boolean} Whether to return a hardened path
6 | * @returns {string}
7 | */
8 | const getSigningPathFromHex = (hexString, hardened = true) => {
9 | // "m/0/0/1"
10 | let signingPath = 'm';
11 | const signingHex = hexString.match(/.{1,8}/g);
12 | const maxNumber = 2147483648 - 1; // 0x80000000
13 | if (signingHex) {
14 | for (const hexNumber of signingHex) {
15 | let number = Number(`0x${hexNumber}`);
16 | if (number > maxNumber) number -= maxNumber;
17 | signingPath += `/${number}${hardened ? "'" : ''}`;
18 | }
19 | }
20 | return signingPath;
21 | };
22 |
23 | export { getSigningPathFromHex };
24 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<"textarea">
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | )
19 | })
20 | Textarea.displayName = "Textarea"
21 |
22 | export { Textarea }
23 |
--------------------------------------------------------------------------------
/src/components/authForm/Input.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | interface InputProps {
4 | error?: boolean;
5 | }
6 |
7 | const Input = styled.input`
8 | background-color: var(--border);
9 | font-size: 14px;
10 | padding: 10px;
11 | margin-bottom: 12px;
12 | height: 40px;
13 | width: 100%;
14 | border-radius: 4px;
15 | border: 1px solid
16 | ${(p) => (p.error ? 'var(--destructive)' : 'var(--border)')};
17 | outline: none;
18 | color: var(--foreground);
19 | transition: border-color 0.2s ease-in-out;
20 |
21 | &:focus {
22 | outline: none;
23 | border: 1px solid ${(p) => (p.error ? 'var(--text-danger)' : 'var(--primary)')};
24 | }
25 |
26 | &::placeholder {
27 | color: var(--muted-foreground);
28 | }
29 | `;
30 |
31 | export default Input;
32 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 | "moduleResolution": "bundler",
9 | "allowImportingTsExtensions": true,
10 | "resolveJsonModule": true,
11 | "isolatedModules": true,
12 | "noEmit": true,
13 | "jsx": "react-jsx",
14 | "strict": true,
15 | "noUnusedLocals": true,
16 | "noUnusedParameters": true,
17 | "noFallthroughCasesInSwitch": true,
18 | "allowJs": true,
19 | "esModuleInterop": true,
20 | "baseUrl": ".",
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["src", "bun-types"],
26 | "references": [{ "path": "./tsconfig.node.json" }]
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | function Separator({
7 | className,
8 | orientation = "horizontal",
9 | decorative = true,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
23 | )
24 | }
25 |
26 | export { Separator }
27 |
--------------------------------------------------------------------------------
/src/components/authForm/SubmitButton.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const SubmitButton = styled.button.attrs(() => ({ type: 'submit' }))`
4 | background-color: ${(p) => p.bgcolor || 'var(--primary)'};
5 | border: 0;
6 | border-radius: 4px;
7 | padding: 12px 8px;
8 | margin: 16px 0 12px 0;
9 | color: var(--primary-foreground);
10 | font-size: 14px;
11 | font-weight: 500;
12 | font-family: inherit;
13 | display: flex;
14 | align-items: center;
15 | justify-content: center;
16 |
17 | &:hover {
18 | background-color: ${(p) => p.bgcolorhover || 'var(--primary)'};
19 | opacity: 0.9;
20 | cursor: pointer;
21 | }
22 |
23 | &:disabled {
24 | background-color: var(--muted);
25 | color: var(--muted-foreground);
26 | }
27 | `;
28 |
29 | export default SubmitButton;
30 |
--------------------------------------------------------------------------------
/src/components/home/HomePage.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 |
3 | import { useDispatch } from 'react-redux';
4 | import { useNavigate } from 'react-router';
5 |
6 | import { loadChannels } from '../../reducers/channelsReducer';
7 | import Layout from './Layout';
8 |
9 | const HomePage = () => {
10 | const dispatch = useDispatch();
11 |
12 | const navigate = useNavigate();
13 |
14 | useEffect(() => {
15 | // if (!authenticated) {
16 | // navigate("/login");
17 | // } else {
18 | dispatch(loadChannels());
19 | //}
20 | }, [dispatch]);
21 |
22 | return (
23 |
24 |
27 |
28 | );
29 | };
30 |
31 | export default HomePage;
32 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build stage
2 | FROM oven/bun:1 as builder
3 | WORKDIR /app
4 |
5 | # Add build arguments
6 | ARG VITE_BMAP_API_URL
7 | ARG VITE_HANDCASH_APP_ID
8 | ARG VITE_HANDCASH_API_URL
9 | ARG VITE_SIGMA_CLIENT_ID
10 | ARG VITE_SIGMA_ISSUER_URL
11 | ARG BITCHAT_MEMBER_WIF
12 |
13 | # Set as environment variables
14 | ENV VITE_BMAP_API_URL=$VITE_BMAP_API_URL
15 | ENV VITE_HANDCASH_APP_ID=$VITE_HANDCASH_APP_ID
16 | ENV VITE_HANDCASH_API_URL=$VITE_HANDCASH_API_URL
17 | ENV VITE_SIGMA_CLIENT_ID=$VITE_SIGMA_CLIENT_ID
18 | ENV VITE_SIGMA_ISSUER_URL=$VITE_SIGMA_ISSUER_URL
19 | ENV BITCHAT_MEMBER_WIF=$BITCHAT_MEMBER_WIF
20 |
21 | COPY package.json bun.lock ./
22 | RUN bun install
23 | COPY . .
24 | RUN bun run build
25 |
26 | # Production stage
27 | FROM caddy:alpine
28 | COPY --from=builder /app/build /srv/
29 | COPY Caddyfile /etc/caddy/Caddyfile
30 |
--------------------------------------------------------------------------------
/test-oauth-manual.js:
--------------------------------------------------------------------------------
1 | fetch('http://localhost:5173')
2 | .then((response) => response.text())
3 | .then((html) => {
4 | if (html.includes('BitChat Nitro')) {
5 | } else {
6 | }
7 | })
8 | .catch((_err) => {});
9 | fetch('https://auth.sigmaidentity.com/.well-known/oauth-authorization-server')
10 | .then((response) => response.json())
11 | .then((data) => {
12 | if (data.authorization_endpoint) {
13 | } else {
14 | }
15 | })
16 | .catch((_err) => {});
17 | const baseUrl = 'https://auth.sigmaidentity.com/authorize';
18 | const params = new URLSearchParams({
19 | client_id: 'bitchat-nitro',
20 | redirect_uri: 'http://localhost:5173/auth/sigma/callback',
21 | response_type: 'code',
22 | provider: 'sigma',
23 | scope: 'openid profile',
24 | state: 'test-state-123',
25 | });
26 |
27 | const _oauthUrl = `${baseUrl}?${params.toString()}`;
28 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "workbench.colorCustomizations": {
3 | "activityBar.activeBackground": "#262654",
4 | "activityBar.background": "#262654",
5 | "activityBar.foreground": "#e7e7e7",
6 | "activityBar.inactiveForeground": "#e7e7e799",
7 | "activityBarBadge.background": "#9a4646",
8 | "activityBarBadge.foreground": "#e7e7e7",
9 | "commandCenter.border": "#e7e7e799",
10 | "sash.hoverBorder": "#262654",
11 | "statusBar.background": "#161631",
12 | "statusBar.foreground": "#e7e7e7",
13 | "statusBarItem.hoverBackground": "#262654",
14 | "statusBarItem.remoteBackground": "#161631",
15 | "statusBarItem.remoteForeground": "#e7e7e7",
16 | "titleBar.activeBackground": "#161631",
17 | "titleBar.activeForeground": "#e7e7e7",
18 | "titleBar.inactiveBackground": "#16163199",
19 | "titleBar.inactiveForeground": "#e7e7e799"
20 | },
21 | "peacock.color": "#161631"
22 | }
23 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test';
2 |
3 | export default defineConfig({
4 | testDir: './tests/e2e',
5 | fullyParallel: true,
6 | forbidOnly: !!process.env.CI,
7 | retries: process.env.CI ? 2 : 0,
8 | workers: process.env.CI ? 1 : undefined,
9 | reporter: 'html',
10 | use: {
11 | baseURL: 'http://localhost:5173',
12 | trace: 'on-first-retry',
13 | screenshot: 'only-on-failure',
14 | },
15 |
16 | projects: [
17 | {
18 | name: 'chromium',
19 | use: { ...devices['Desktop Chrome'] },
20 | },
21 | {
22 | name: 'firefox',
23 | use: { ...devices['Desktop Firefox'] },
24 | },
25 | {
26 | name: 'webkit',
27 | use: { ...devices['Desktop Safari'] },
28 | },
29 | ],
30 |
31 | webServer: {
32 | command: 'bun run dev',
33 | port: 5173,
34 | reuseExistingServer: !process.env.CI,
35 | },
36 | });
37 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
3 | "vcs": {
4 | "enabled": true,
5 | "clientKind": "git",
6 | "useIgnoreFile": true
7 | },
8 | "files": {
9 | "ignoreUnknown": true,
10 | "includes": ["src/**/*.ts", "src/**/*.tsx", "!**/src/components/ui"]
11 | },
12 | "formatter": {
13 | "enabled": true,
14 | "indentStyle": "tab",
15 | "indentWidth": 2,
16 | "lineWidth": 100
17 | },
18 | "linter": {
19 | "enabled": true,
20 | "rules": {
21 | "recommended": true,
22 | "correctness": {
23 | "noUnusedImports": "warn",
24 | "noUnusedVariables": "warn"
25 | },
26 | "suspicious": {
27 | "noExplicitAny": "off"
28 | },
29 | "style": {
30 | "noNonNullAssertion": "off",
31 | "useImportType": "off"
32 | }
33 | }
34 | },
35 | "javascript": {
36 | "formatter": {
37 | "quoteStyle": "single",
38 | "jsxQuoteStyle": "double"
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/tests/e2e/homepage.spec.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '@playwright/test';
2 |
3 | test.describe('Homepage', () => {
4 | test('should load and display login button', async ({ page }) => {
5 | await page.goto('/');
6 |
7 | // Check that the page loads
8 | await expect(page).toHaveTitle('BitChat Nitro');
9 |
10 | // Check for login button
11 | const loginButton = page.getByRole('button', { name: /login/i });
12 | await expect(loginButton).toBeVisible();
13 | });
14 |
15 | test('should navigate to login page', async ({ page }) => {
16 | await page.goto('/');
17 |
18 | // Click login button
19 | await page.getByRole('button', { name: /login/i }).click();
20 |
21 | // Should be on login page
22 | await expect(page).toHaveURL('/login');
23 |
24 | // Check for wallet options
25 | await expect(page.getByText('Yours Wallet')).toBeVisible();
26 | await expect(page.getByText('HandCash')).toBeVisible();
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/components/authForm/Select.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const Select = styled.select`
4 | margin-top: 6px;
5 | margin-bottom: 12px;
6 | padding: 10px 8px 8px 12px;
7 | background-color: var(--card);
8 | border: 1px solid var(--border);
9 | color: var(--foreground);
10 | font-size: 14px;
11 | border-radius: 5px;
12 | -webkit-appearance: none;
13 | -moz-appearance: none;
14 | appearance: none;
15 |
16 | &:focus {
17 | outline: none;
18 | }
19 |
20 | /* https://codepen.io/vkjgr/pen/VYMeXp */
21 | background-image: linear-gradient(45deg, transparent 50%, gray 50%),
22 | linear-gradient(135deg, gray 50%, transparent 50%);
23 | background-position: calc(100% - 20px) calc(1em + 2px),
24 | calc(100% - 15px) calc(1em + 2px);
25 | background-size: 5px 5px, 5px 5px;
26 | background-repeat: no-repeat;
27 |
28 | &:-moz-focusring {
29 | color: transparent;
30 | text-shadow: 0 0 0 #000;
31 | }
32 | `;
33 |
34 | export default Select;
35 |
--------------------------------------------------------------------------------
/src/components/authForm/Layout.tsx:
--------------------------------------------------------------------------------
1 | import type { FC, ReactNode } from 'react';
2 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
3 |
4 | interface LayoutProps {
5 | heading: string;
6 | children: ReactNode;
7 | }
8 |
9 | const Layout: FC = ({ heading, children }) => {
10 | return (
11 |
12 |
13 |
14 |
15 |

16 |
17 |
18 | Welcome to BitChat
19 | {heading}
20 |
21 |
22 | {children}
23 |
24 |
25 | );
26 | };
27 |
28 | export default Layout;
29 |
--------------------------------------------------------------------------------
/src/components/dashboard/modals/FilePreviewModal.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { Dialog, DialogContent } from '@/components/ui/dialog';
3 | import { getBase64Url } from '../../../utils/file';
4 |
5 | interface FileType {
6 | txid?: string;
7 | name?: string;
8 | url?: string;
9 | 'content-type'?: string;
10 | media_type?: string;
11 | }
12 |
13 | interface FilePreviewModalProps {
14 | open: boolean;
15 | onOpenChange: (open: boolean) => void;
16 | file: FileType | null;
17 | }
18 |
19 | const FilePreviewModal = ({ open, onOpenChange, file }: FilePreviewModalProps) => {
20 | const b64 = useMemo(() => (file ? getBase64Url(file) : null), [file]);
21 |
22 | if (!b64) {
23 | return null;
24 | }
25 |
26 | return (
27 |
32 | );
33 | };
34 |
35 | export default FilePreviewModal;
36 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Bitchat [NITRO]
2 |
3 | A real-time chat application over Bitcoin. An alternative interface to the original Bitchat.
4 |
5 | Website: [https://bitchatnitro.com](https://bitchatnitro.com)
6 |
7 | 
8 |
9 | ## Unimplemented Features
10 |
11 | x Show who is typing.
12 | x Show who is online.
13 | x Allow users to edit/delete their own messages.
14 | x Private messaging
15 |
16 | ## Technologies
17 |
18 | - JavaScript
19 | - React
20 | - Redux
21 | - Styled components
22 |
23 | ## Installation
24 |
25 | - Clone the repo.
26 | - Run `npm install` to install dependencies for the server.
27 | - Run `cd client` and then `npm install` to install dependencies for the client.
28 |
29 | ## Local Development
30 |
31 | - Create a `.env` file in `server`, following the format of the `.evn.example` file. Fill in the details.
32 | - Run `npm run client` to start the client.
33 | - Run `npm run server` to start the server.
34 | - Run `npm run dev` to start the client and the server concurrently.
35 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export { Input }
22 |
--------------------------------------------------------------------------------
/src/config/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Application Constants
3 | *
4 | * All API URLs and configuration constants.
5 | * These are public endpoints - no sensitive data here.
6 | */
7 |
8 | // BMAP API - for SSE streams, messages, social features
9 | export const API_BASE_URL = 'https://bmap-api-production.up.railway.app';
10 |
11 | // Sigma API - for identity search, profiles, social overlay
12 | export const SIGMA_API_URL = 'https://api.sigmaidentity.com';
13 |
14 | // Nitro API - for Droplit proxy with platform auth
15 | export const NITRO_API_URL = 'https://api.bitchatnitro.com';
16 |
17 | // Sigma Auth - for OAuth authentication only
18 | export const SIGMA_AUTH_URL = 'https://auth.sigmaidentity.com';
19 |
20 | // Droplit API - for transaction creation and funding
21 | export const DROPLIT_API_URL = 'https://dev-go-faucet-api-mazi.encr.app';
22 |
23 | // Droplit faucet name for BitChat
24 | export const DROPLIT_FAUCET_NAME = 'bitchat';
25 |
26 | // HandCash App ID (optional, can be undefined for local dev)
27 | export const HANDCASH_APP_ID = import.meta.env.VITE_HANDCASH_APP_ID;
28 |
--------------------------------------------------------------------------------
/src/components/dashboard/SubmitButton.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import styled from 'styled-components';
3 |
4 | interface SubmitButtonProps extends React.ButtonHTMLAttributes {
5 | disabled?: boolean;
6 | }
7 |
8 | const Button = styled.button<{ disabled?: boolean }>`
9 | background-color: ${({ disabled }) =>
10 | disabled ? 'var(--button-secondary-background)' : 'var(--button-primary)'};
11 | color: ${({ disabled }) => (disabled ? 'var(--muted-foreground)' : 'var(--foreground)')};
12 | border: none;
13 | border-radius: 3px;
14 | padding: 0.5rem 1rem;
15 | font-size: 0.875rem;
16 | font-weight: 500;
17 | cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
18 | transition: background-color 0.2s ease;
19 |
20 | &:hover {
21 | background-color: ${({ disabled }) =>
22 | disabled ? 'var(--button-secondary-background)' : 'var(--button-primary-hover)'};
23 | }
24 | `;
25 |
26 | const SubmitButton: React.FC = ({ disabled, ...props }) => {
27 | return ;
28 | };
29 |
30 | export default SubmitButton;
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 King Yiu Suen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/src/utils/storage.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | export const lsTest = () => {
4 | const test = 'test';
5 | try {
6 | localStorage.setItem(test, test);
7 | localStorage.removeItem(test);
8 | return true;
9 | } catch (_e) {
10 | return false;
11 | }
12 | };
13 |
14 | export function useLocalStorage(
15 | key: string,
16 | initialValue?: T,
17 | ): [T | null, (value: T | null) => void] {
18 | const [storedValue, setStoredValue] = useState(() => {
19 | if (!lsTest()) {
20 | return initialValue ?? null;
21 | }
22 |
23 | try {
24 | const item = localStorage.getItem(key);
25 | return item ? JSON.parse(item) : (initialValue ?? null);
26 | } catch (error) {
27 | console.error(error);
28 | return initialValue ?? null;
29 | }
30 | });
31 |
32 | const setValue = (value: T | null) => {
33 | try {
34 | setStoredValue(value);
35 | if (value === null) {
36 | localStorage.removeItem(key);
37 | } else {
38 | localStorage.setItem(key, JSON.stringify(value));
39 | }
40 | } catch (error) {
41 | console.error(error);
42 | }
43 | };
44 |
45 | return [storedValue, setValue];
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/dashboard/MessageFiles.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import { useState } from 'react';
3 | import MessageFile from './MessageFile';
4 | import FilePreviewModal from './modals/FilePreviewModal';
5 |
6 | interface File {
7 | txid?: string;
8 | name?: string;
9 | url?: string;
10 | 'content-type'?: string;
11 | media_type?: string;
12 | }
13 |
14 | interface MessageFilesProps {
15 | files?: File[];
16 | }
17 |
18 | const MessageFiles: React.FC = ({ files }) => {
19 | const [selectedFile, setSelectedFile] = useState(null);
20 |
21 | if (!files || files.length === 0) {
22 | return null;
23 | }
24 |
25 | return (
26 | <>
27 |
28 | {files.map((file, index) => (
29 | setSelectedFile(file)}
33 | />
34 | ))}
35 |
36 |
37 | !open && setSelectedFile(null)}
41 | />
42 | >
43 | );
44 | };
45 |
46 | export default MessageFiles;
47 |
--------------------------------------------------------------------------------
/src/components/dashboard/Dashboard.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { useParams } from 'react-router-dom';
3 | import ChatArea from './ChatArea';
4 | import { Friends } from './Friends';
5 | import Header from './Header';
6 | import { MemberList } from './MemberList';
7 | import { SettingsModal } from './modals/SettingsModal';
8 | import Sidebar from './Sidebar';
9 |
10 | interface DashboardProps {
11 | isFriendsPage: boolean;
12 | }
13 |
14 | export const Dashboard: FC = ({ isFriendsPage }) => {
15 | const params = useParams<{ user?: string; channel?: string }>();
16 | const isUserPage = Boolean(params.user);
17 |
18 | const renderMainContent = () => {
19 | if (isFriendsPage) {
20 | return ;
21 | }
22 |
23 | if (isUserPage) {
24 | return ;
25 | }
26 |
27 | return ;
28 | };
29 |
30 | return (
31 |
32 |
33 |
34 |
35 | {renderMainContent()}
36 |
37 |
38 |
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SwitchPrimitives from "@radix-ui/react-switch"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ))
25 | Switch.displayName = SwitchPrimitives.Root.displayName
26 |
27 | export { Switch }
28 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/src/components/dashboard/MessageFile.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import { useMemo } from 'react';
3 | import styled from 'styled-components';
4 | import { getBase64Url } from '../../utils/file';
5 | import FileRenderer, { type MediaType } from './FileRenderer';
6 |
7 | interface MessageFileProps {
8 | file: {
9 | 'content-type'?: string;
10 | media_type?: string;
11 | };
12 | onClick?: () => void;
13 | }
14 |
15 | const Container = styled.div`
16 | flex: 1;
17 | border-radius: 8px;
18 | overflow: hidden;
19 | min-width: 260px;
20 | max-width: 100%;
21 | max-height: 100%;
22 | position: relative;
23 | cursor: pointer;
24 | aspect-ratio: 16/9;
25 |
26 | @media (min-width: 768px) {
27 | max-width: 320px;
28 | max-height: 320px;
29 | }
30 |
31 | img {
32 | width: 100%;
33 | object-fit: contain;
34 | }
35 | `;
36 |
37 | const MessageFile: React.FC = ({ file, onClick }) => {
38 | const b64 = useMemo(() => getBase64Url(file), [file]);
39 |
40 | if (!b64) {
41 | return null;
42 | }
43 |
44 | const contentType = file['content-type'] ?? file.media_type;
45 | const fileType = contentType?.split('/')[0];
46 |
47 | return (
48 |
49 |
50 |
51 | );
52 | };
53 |
54 | export default MessageFile;
55 |
--------------------------------------------------------------------------------
/src/context/bmap/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { type PropsWithChildren, useCallback, useContext, useMemo } from 'react';
2 | import { API_BASE_URL } from '../../config/constants';
3 |
4 | interface BmapContextType {
5 | notifyIndexer: (rawTx: string) => Promise;
6 | }
7 |
8 | const BmapContext = React.createContext(undefined);
9 |
10 | const BmapProvider = (props: PropsWithChildren) => {
11 | const notifyIndexer = useCallback((rawTx: string) => {
12 | return new Promise((resolve, reject) => {
13 | fetch(`${API_BASE_URL}/ingest`, {
14 | method: 'POST',
15 | headers: {
16 | 'Content-Type': 'application/json',
17 | },
18 | body: JSON.stringify({ rawTx }),
19 | })
20 | .then((resp) => resp.json())
21 | .then((json) => resolve(json))
22 | .catch((e) => {
23 | reject(new Error('Failed to notify indexer', e));
24 | });
25 | });
26 | }, []);
27 |
28 | const value = useMemo(
29 | () => ({
30 | notifyIndexer,
31 | }),
32 | [notifyIndexer],
33 | );
34 |
35 | return ;
36 | };
37 |
38 | const useBmap = (): BmapContextType => {
39 | const context = useContext(BmapContext);
40 | if (context === undefined) {
41 | throw new Error('useBmap must be used within an BmapProvider');
42 | }
43 | return context;
44 | };
45 |
46 | export { BmapProvider, useBmap };
47 |
48 | //
49 | // Utils
50 | //
51 |
--------------------------------------------------------------------------------
/src/components/common/ConfirmationModal.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import {
3 | AlertDialog,
4 | AlertDialogAction,
5 | AlertDialogCancel,
6 | AlertDialogContent,
7 | AlertDialogDescription,
8 | AlertDialogFooter,
9 | AlertDialogHeader,
10 | AlertDialogTitle,
11 | } from '@/components/ui/alert-dialog';
12 |
13 | interface ConfirmationModalProps {
14 | open: boolean;
15 | onOpenChange: (open: boolean) => void;
16 | onConfirm: () => void;
17 | title: string;
18 | message: string;
19 | confirmText?: string;
20 | cancelText?: string;
21 | }
22 |
23 | const ConfirmationModal: FC = ({
24 | open,
25 | onOpenChange,
26 | onConfirm,
27 | title,
28 | message,
29 | confirmText = 'Confirm',
30 | cancelText = 'Cancel',
31 | }) => {
32 | const handleConfirm = () => {
33 | onConfirm();
34 | onOpenChange(false);
35 | };
36 |
37 | return (
38 |
39 |
40 |
41 | {title}
42 | {message}
43 |
44 |
45 | {cancelText}
46 | {confirmText}
47 |
48 |
49 |
50 | );
51 | };
52 |
53 | export default ConfirmationModal;
54 |
--------------------------------------------------------------------------------
/src/constants/servers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Server definitions for BitChat Nitro
3 | *
4 | * - isNative: true means this is the native BitChat interface (not loaded in iframe)
5 | * - isNative: false means this server will be loaded in an iframe
6 | */
7 |
8 | export interface ServerDefinition {
9 | _id: string;
10 | name: string;
11 | description: string;
12 | icon: string;
13 | paymail?: string;
14 | isNative: boolean;
15 | url?: string; // URL to load in iframe (only for non-native servers)
16 | }
17 |
18 | export const SERVERS: ServerDefinition[] = [
19 | {
20 | _id: 'bitchat',
21 | name: 'BitChat',
22 | description: 'The main BitChat server',
23 | icon: '/images/blockpost-logo.svg',
24 | paymail: 'bitchat@bitchatnitro.com',
25 | isNative: true, // This is the native interface, not an iframe
26 | },
27 | // Future servers can be added here with isNative: false
28 | // Example:
29 | // {
30 | // _id: 'other-server',
31 | // name: 'Other Server',
32 | // description: 'Another server',
33 | // icon: '/images/other-server.svg',
34 | // isNative: false,
35 | // url: 'https://other-server.com',
36 | // },
37 | ];
38 |
39 | export const getBitchatServer = (): ServerDefinition => {
40 | const server = SERVERS.find((s) => s._id === 'bitchat');
41 | if (!server) {
42 | throw new Error('BitChat server not found in server definitions');
43 | }
44 | return server;
45 | };
46 |
47 | export const getServerById = (id: string): ServerDefinition | undefined => {
48 | return SERVERS.find((s) => s._id === id);
49 | };
50 |
--------------------------------------------------------------------------------
/src/styles/GlobalStyles.tsx:
--------------------------------------------------------------------------------
1 | import { Global } from '@emotion/react';
2 |
3 | const customStyles = {
4 | 'html, body': {
5 | margin: 0,
6 | padding: 0,
7 | fontFamily: 'var(--font-sans)',
8 | fontSize: '16px',
9 | lineHeight: '1.5',
10 | color: 'var(--foreground)',
11 | backgroundColor: 'var(--background)',
12 | WebkitFontSmoothing: 'antialiased',
13 | MozOsxFontSmoothing: 'grayscale',
14 | },
15 | '*': {
16 | boxSizing: 'border-box' as const,
17 | },
18 | '#root': {
19 | height: '100vh',
20 | overflow: 'hidden',
21 | },
22 | // Scrollbar styling
23 | '::-webkit-scrollbar': {
24 | width: '8px',
25 | height: '8px',
26 | },
27 | '::-webkit-scrollbar-track': {
28 | backgroundColor: 'transparent',
29 | },
30 | '::-webkit-scrollbar-thumb': {
31 | backgroundColor: 'var(--muted-foreground)',
32 | borderRadius: '4px',
33 | opacity: 0.3,
34 | },
35 | '::-webkit-scrollbar-thumb:hover': {
36 | opacity: 0.5,
37 | },
38 | '*:focus': {
39 | outline: 'none',
40 | },
41 | 'button, a, input, textarea': {
42 | transition: 'all 0.15s ease',
43 | },
44 | 'h1, h2, h3, h4, h5, h6, p': {
45 | margin: 0,
46 | },
47 | button: {
48 | background: 'none',
49 | border: 'none',
50 | color: 'inherit',
51 | font: 'inherit',
52 | cursor: 'pointer',
53 | },
54 | 'input, textarea': {
55 | background: 'transparent',
56 | border: 'none',
57 | color: 'inherit',
58 | fontFamily: 'inherit',
59 | outline: 'none',
60 | },
61 | };
62 |
63 | export const GlobalStyles = () => ;
64 |
--------------------------------------------------------------------------------
/src/utils/next-polyfill.js:
--------------------------------------------------------------------------------
1 | // Mock Next.js modules for Vite/React environment
2 | export const NextResponse = {
3 | json: (data) => ({ ok: true, json: () => Promise.resolve(data) }),
4 | next: () => ({ ok: true }),
5 | redirect: (url) => ({ ok: true, redirected: true, url }),
6 | };
7 |
8 | export const NextRequest = class {
9 | constructor(url) {
10 | this.url = url;
11 | }
12 | };
13 |
14 | export const useRouter = () => ({
15 | push: (path) =>
16 | console.warn('useRouter.push called in non-Next environment:', path),
17 | replace: (path) =>
18 | console.warn('useRouter.replace called in non-Next environment:', path),
19 | back: () => console.warn('useRouter.back called in non-Next environment'),
20 | });
21 |
22 | export const usePathname = () => {
23 | console.warn('usePathname called in non-Next environment');
24 | return '/';
25 | };
26 |
27 | export const useSearchParams = () => {
28 | console.warn('useSearchParams called in non-Next environment');
29 | return new URLSearchParams();
30 | };
31 |
32 | export const headers = () => {
33 | console.warn('headers() called in non-Next environment');
34 | return new Map();
35 | };
36 |
37 | export const cookies = () => {
38 | console.warn('cookies() called in non-Next environment');
39 | return new Map();
40 | };
41 |
42 | export default function dynamic() {
43 | console.warn('dynamic() called in non-Next environment');
44 | return () => null;
45 | }
46 |
47 | // Default exports for different import patterns
48 | export { NextResponse as default };
49 |
--------------------------------------------------------------------------------
/src/components/dashboard/FileRenderer.tsx:
--------------------------------------------------------------------------------
1 | import type { JSX } from 'react';
2 | import React, { forwardRef, useState } from 'react';
3 |
4 | export type MediaType = 'image' | 'video' | 'audio';
5 |
6 | interface FileRendererProps {
7 | type: MediaType;
8 | data: string;
9 | onClick?: () => void;
10 | }
11 |
12 | type MediaElementType = HTMLImageElement | HTMLVideoElement | HTMLAudioElement;
13 |
14 | interface MediaElementProps {
15 | alt?: string;
16 | className?: string;
17 | controls?: boolean;
18 | style?: React.CSSProperties;
19 | }
20 |
21 | const mediaElement: Record = {
22 | image: 'img',
23 | video: 'video',
24 | audio: 'audio',
25 | };
26 |
27 | const mediaElementProps: Record = {
28 | image: {
29 | alt: 'Media content',
30 | },
31 | video: {
32 | className: 'object-contain w-full h-full',
33 | controls: true,
34 | },
35 | audio: {
36 | className: 'w-full',
37 | controls: true,
38 | },
39 | };
40 |
41 | const FileRenderer = forwardRef(
42 | ({ type, data, onClick }, ref) => {
43 | const [hasError, setHasError] = useState(false);
44 | const MediaElement = mediaElement[type];
45 |
46 | if (!MediaElement || hasError) {
47 | return null;
48 | }
49 |
50 | return React.createElement(MediaElement, {
51 | ref,
52 | src: data,
53 | onClick,
54 | onError: () => setHasError(true),
55 | ...mediaElementProps[type],
56 | });
57 | },
58 | );
59 |
60 | FileRenderer.displayName = 'FileRenderer';
61 |
62 | export default FileRenderer;
63 |
--------------------------------------------------------------------------------
/src/store.ts:
--------------------------------------------------------------------------------
1 | import type { Action, ThunkAction } from '@reduxjs/toolkit';
2 | import { configureStore } from '@reduxjs/toolkit';
3 | import socketMiddleware from './middleware/socketMiddleware';
4 | import channelsReducer from './reducers/channelsReducer';
5 | import chatReducer from './reducers/chatReducer';
6 | import memberListReducer from './reducers/memberListReducer';
7 | import profileReducer from './reducers/profileReducer';
8 | import serverReducer from './reducers/serverReducer';
9 | import sessionReducer from './reducers/sessionReducer';
10 | import settingsReducer from './reducers/settingsReducer';
11 | import sidebarReducer from './reducers/sidebarReducer';
12 |
13 | const store = configureStore({
14 | reducer: {
15 | session: sessionReducer,
16 | channels: channelsReducer,
17 | chat: chatReducer,
18 | memberList: memberListReducer,
19 | servers: serverReducer,
20 | sidebar: sidebarReducer,
21 | settings: settingsReducer,
22 | profile: profileReducer,
23 | },
24 | middleware: (getDefaultMiddleware) =>
25 | getDefaultMiddleware({
26 | serializableCheck: {
27 | ignoredActions: [
28 | 'channels/loadChannels/fulfilled',
29 | 'channels/loadChannels/rejected',
30 | 'channels/loadChannels/pending',
31 | ],
32 | },
33 | }).concat(socketMiddleware),
34 | });
35 |
36 | export type AppDispatch = typeof store.dispatch;
37 | export type RootState = ReturnType;
38 | export type AppThunk = ThunkAction<
39 | ReturnType,
40 | RootState,
41 | unknown,
42 | Action
43 | >;
44 |
45 | export default store;
46 |
--------------------------------------------------------------------------------
/src/components/home/Layout.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { FaDiscord } from 'react-icons/fa';
4 | import styled from 'styled-components';
5 |
6 | const Wrapper = styled.div`
7 | display: flex;
8 | align-items: center;
9 | background-color: var(--brand);
10 | min-height: 100dvh;
11 | width: 100%;
12 | color: var(--muted-foreground);
13 | justify-content: center;
14 | `;
15 |
16 | const Container = styled.div`
17 | padding: 28px;
18 | background-color: var(--background);
19 | box-shadow: 0 2px 10px 0 rgb(0 0 0 / 0.2);
20 | border-radius: 5px;
21 | display: flex;
22 | flex-direction: column;
23 | align-items: center;
24 |
25 | @media only screen and (max-width: 550px) {
26 | padding: 16px;
27 | min-height: 100dvh;
28 | min-width: 100vw;
29 | border-radius: 0;
30 | justify-content: flex-start;
31 | }
32 | `;
33 |
34 | const Header = styled.div`
35 | display: flex;
36 | flex-direction: column;
37 | justify-content: center;
38 | text-align: center;
39 | font-size: 40px;
40 | color: var(--primary);
41 | margin-bottom: 20px;
42 | `;
43 |
44 | const Heading = styled.h1`
45 | color: var(--header-primary);
46 | font-size: 24px;
47 | font-weight: 500;
48 | `;
49 |
50 | const Layout = ({ heading, children }) => {
51 | return (
52 |
53 |
54 |
57 | {heading}
58 | {children}
59 |
60 |
61 | );
62 | };
63 |
64 | export default Layout;
65 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Avatar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ))
19 | Avatar.displayName = AvatarPrimitive.Root.displayName
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ))
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ))
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
47 |
48 | export { Avatar, AvatarImage, AvatarFallback }
49 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeTokenProvider } from '@theme-token/sdk/react';
2 | import React from 'react';
3 | import { createRoot } from 'react-dom/client';
4 | import { Provider } from 'react-redux';
5 | import { YoursProvider } from 'yours-wallet-provider';
6 | import App from './App';
7 | import { BapProvider } from './context/bap';
8 | import { BitcoinProvider } from './context/bitcoin';
9 | import { BmapProvider } from './context/bmap';
10 | import { HandcashProvider } from './context/handcash';
11 | import { ThemeProvider } from './context/theme';
12 | import { AutoYoursProvider } from './context/yours';
13 | import './index.css';
14 | import store from './store';
15 |
16 | const rootElement = document.getElementById('root');
17 | if (!rootElement) throw new Error('Failed to find the root element');
18 |
19 | const root = createRoot(rootElement);
20 |
21 | // Note: Sigma iframe signer is initialized lazily when needed for transaction signing
22 | // OAuth authentication does not use the iframe - it's a pure redirect flow
23 |
24 | root.render(
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | ,
46 | );
47 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Build output
2 | /dist
3 | /dist-ssr
4 | /build
5 | *.local
6 |
7 | # Performance testing
8 | /performance_results
9 | server.log
10 | curl-format.txt
11 | measure_performance.sh
12 |
13 | # Logs
14 | logs
15 | *.log
16 | npm-debug.log*
17 | yarn-debug.log*
18 | yarn-error.log*
19 | firebase-debug.log*
20 | firebase-debug.*.log*
21 |
22 | # Firebase cache
23 | .firebase/
24 |
25 | # Firebase config
26 | .firebaserc
27 |
28 | # Uncomment this if you'd like others to create their own Firebase project.
29 | # For a team working on the same Firebase project(s), it is recommended to leave
30 | # it commented so all members can deploy to the same project(s) in .firebaserc.
31 | # .firebaserc
32 |
33 | # Runtime data
34 | pids
35 | *.pid
36 | *.seed
37 | *.pid.lock
38 |
39 | # Directory for instrumented libs generated by jscoverage/JSCover
40 | lib-cov
41 |
42 | # Coverage directory used by tools like istanbul
43 | coverage
44 |
45 | # nyc test coverage
46 | .nyc_output
47 |
48 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
49 | .grunt
50 |
51 | # Bower dependency directory (https://bower.io/)
52 | bower_components
53 |
54 | # node-waf configuration
55 | .lock-wscript
56 |
57 | # Compiled binary addons (http://nodejs.org/api/addons.html)
58 | build/Release
59 |
60 | # Dependency directories
61 | node_modules/
62 |
63 | # Optional npm cache directory
64 | .npm
65 |
66 | # Environment files
67 | .env.local
68 | .env.*.local
69 |
70 | # Output of 'npm pack'
71 | *.tgz
72 |
73 | # Yarn Integrity file
74 | .yarn-integrity
75 |
76 | # dotenv environment variables file
77 | .env
78 |
79 |
80 | # mac junk
81 | .DS_Store
82 | public/.playwright-mcp/
83 |
--------------------------------------------------------------------------------
/src/components/dashboard/ProfilePanel.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import { useSelector } from 'react-redux';
3 | import { cn } from '@/lib/utils';
4 | import type { RootState } from '../../types';
5 | import Avatar from './Avatar';
6 |
7 | const ProfilePanel: React.FC = () => {
8 | const session = useSelector((state: RootState) => state.session);
9 | const isOpen = useSelector((state: RootState) => state.profile.isOpen);
10 |
11 | if (!session.user) {
12 | return null;
13 | }
14 |
15 | return (
16 |
22 |
23 |
24 |
25 |
26 |
{session.user.paymail}
27 |
Online
28 |
29 |
30 |
31 |
32 |
33 |
Wallet
34 |
{session.user.wallet}
35 |
36 |
37 | {session.user.address && (
38 |
39 |
Address
40 |
{session.user.address}
41 |
42 | )}
43 |
44 | );
45 | };
46 |
47 | export default ProfilePanel;
48 |
--------------------------------------------------------------------------------
/src/components/dashboard/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useSelector } from 'react-redux';
3 | import { useParams } from 'react-router-dom';
4 | import {
5 | Sidebar as ShadcnSidebar,
6 | SidebarContent,
7 | SidebarFooter,
8 | SidebarProvider,
9 | } from '@/components/ui/sidebar';
10 | import type { RootState } from '../../store';
11 | import ChannelList from './ChannelList';
12 | import ServerList from './ServerList';
13 | import { UserList } from './UserList';
14 | import UserPanel from './UserPanel';
15 |
16 | // Inner sidebar content that uses the sidebar context
17 | function SidebarInner() {
18 | const params = useParams();
19 | const activeUserId = useMemo(() => params.user, [params]);
20 |
21 | return (
22 |
23 |
24 | {!activeUserId && }
25 | {activeUserId && }
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | // Server list sidebar (narrow icons)
35 | function ServerSidebar() {
36 | return (
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | const Sidebar = () => {
44 | const isOpen = useSelector((state: RootState) => state.sidebar.isOpen);
45 |
46 | return (
47 |
51 |
52 |
53 |
54 |
55 |
56 | );
57 | };
58 |
59 | export default Sidebar;
60 |
--------------------------------------------------------------------------------
/tests/visual/README.md:
--------------------------------------------------------------------------------
1 | # Visual Regression Testing Suite
2 |
3 | This directory contains automated visual regression tests to help debug UI components and catch visual regressions during development.
4 |
5 | ## Quick Start
6 |
7 | ```bash
8 | # Run visual tests with current dev server
9 | bun run test:visual
10 |
11 | # Update visual baselines (run this after confirming changes are correct)
12 | bun run test:visual:update
13 |
14 | # Run specific component tests
15 | bun run test:component:modals
16 |
17 | # Take screenshots of current state
18 | bun run test:screenshot
19 | ```
20 |
21 | ## Test Categories
22 |
23 | ### 1. Visual Regression Tests (`visual.spec.ts`)
24 | - Full page screenshots across different viewport sizes
25 | - Component-specific visual tests (modals, buttons, lists)
26 | - Dark theme validation
27 | - Cross-browser compatibility
28 |
29 | ### 2. Component Tests (`components/`)
30 | - Individual component testing
31 | - State-based screenshot comparisons
32 | - Interactive element validation
33 |
34 | ### 3. Debug Helpers (`debug/`)
35 | - Auto-screenshot on errors
36 | - Component state debugging
37 | - Performance profiling
38 |
39 | ## Configuration
40 |
41 | Visual tests use specific viewport sizes and wait conditions to ensure consistent results:
42 | - Desktop: 1400x900
43 | - Mobile: 375x667
44 | - Tablet: 768x1024
45 |
46 | ## Usage Tips
47 |
48 | 1. **Always run dev server first**: `bun run dev`
49 | 2. **Update baselines carefully**: Only after visual inspection
50 | 3. **Use component selectors**: Target specific elements for faster tests
51 | 4. **Mock dynamic content**: Timestamp, random data, etc.
52 |
53 | ## Debugging
54 |
55 | When tests fail, check the HTML report:
56 | ```bash
57 | bun run playwright show-report
58 | ```
59 |
60 | The report will show expected vs actual with diff highlighting.
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const ScrollArea = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, children, ...props }, ref) => (
10 |
15 |
16 | {children}
17 |
18 |
19 |
20 |
21 | ))
22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
23 |
24 | const ScrollBar = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, orientation = "vertical", ...props }, ref) => (
28 |
41 |
42 |
43 | ))
44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
45 |
46 | export { ScrollArea, ScrollBar }
47 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/reducers/serverReducer.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
2 | import { SERVERS, type ServerDefinition } from '../constants/servers';
3 | import type { AppDispatch } from '../store';
4 |
5 | interface ServerState {
6 | loading: boolean;
7 | error: string | null;
8 | data: ServerDefinition[];
9 | }
10 |
11 | const initialState: ServerState = {
12 | loading: false,
13 | error: null,
14 | data: SERVERS,
15 | };
16 |
17 | const serverSlice = createSlice({
18 | name: 'servers',
19 | initialState,
20 | reducers: {
21 | fetchServersStart(state) {
22 | state.loading = true;
23 | state.error = null;
24 | },
25 | fetchServersSuccess(state, action: PayloadAction) {
26 | state.loading = false;
27 | state.data = action.payload;
28 | },
29 | fetchServersFailure(state, action: PayloadAction) {
30 | state.loading = false;
31 | state.error = action.payload;
32 | },
33 | addServer(state, action: PayloadAction) {
34 | state.data.push(action.payload);
35 | },
36 | removeServer(state, action: PayloadAction) {
37 | state.data = state.data.filter((server) => server._id !== action.payload);
38 | },
39 | },
40 | });
41 |
42 | export const {
43 | fetchServersStart,
44 | fetchServersSuccess,
45 | fetchServersFailure,
46 | addServer,
47 | removeServer,
48 | } = serverSlice.actions;
49 |
50 | export const fetchServers = () => async (dispatch: AppDispatch) => {
51 | try {
52 | dispatch(fetchServersStart());
53 | // Since there's no API endpoint yet, we'll just return success with the server definitions
54 | dispatch(fetchServersSuccess(SERVERS));
55 | } catch (error) {
56 | dispatch(
57 | fetchServersFailure(error instanceof Error ? error.message : 'Failed to fetch servers'),
58 | );
59 | }
60 | };
61 |
62 | export default serverSlice.reducer;
63 |
--------------------------------------------------------------------------------
/src/components/dashboard/modals/PinChannelModal.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import { useState } from 'react';
3 | import { Button } from '@/components/ui/button';
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogDescription,
8 | DialogFooter,
9 | DialogHeader,
10 | DialogTitle,
11 | } from '@/components/ui/dialog';
12 | import { Input } from '@/components/ui/input';
13 |
14 | export const minutesPerUnit = 60;
15 |
16 | interface PinChannelModalProps {
17 | open: boolean;
18 | onOpenChange: (open: boolean) => void;
19 | onConfirm: (units: number) => void;
20 | }
21 |
22 | const PinChannelModal: React.FC = ({ open, onOpenChange, onConfirm }) => {
23 | const [units, setUnits] = useState(1);
24 |
25 | const handleConfirm = () => {
26 | onConfirm(units);
27 | onOpenChange(false);
28 | };
29 |
30 | return (
31 |
60 | );
61 | };
62 |
63 | export default PinChannelModal;
64 |
--------------------------------------------------------------------------------
/src/reducers/settingsReducer.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 |
3 | export const SETTINGS_OPTIONS = {
4 | HIDE: 'HIDE',
5 | SHOW: 'SHOW',
6 | } as const;
7 |
8 | type SettingsOption = (typeof SETTINGS_OPTIONS)[keyof typeof SETTINGS_OPTIONS];
9 |
10 | interface SettingsState {
11 | hideUnverifiedMessages: boolean;
12 | isOpen: boolean;
13 | }
14 |
15 | // Simple localStorage access for settings
16 | const getStoredSetting = (key: string, defaultValue: boolean): boolean => {
17 | try {
18 | const stored = localStorage.getItem(key);
19 | return stored ? JSON.parse(stored) : defaultValue;
20 | } catch {
21 | return defaultValue;
22 | }
23 | };
24 |
25 | const saveStoredSetting = (key: string, value: SettingsOption): void => {
26 | try {
27 | localStorage.setItem(key, JSON.stringify(value));
28 | } catch {
29 | // Ignore storage errors
30 | }
31 | };
32 |
33 | const hideUnverifiedMessagesInitState = getStoredSetting('settings.hideUnverifiedMessages', true);
34 |
35 | const initialState: SettingsState = {
36 | hideUnverifiedMessages: hideUnverifiedMessagesInitState,
37 | isOpen: false,
38 | };
39 |
40 | const settingsSlice = createSlice({
41 | name: 'settings',
42 | initialState,
43 | reducers: {
44 | toggleHideUnverifiedMessages(state) {
45 | state.hideUnverifiedMessages = !state.hideUnverifiedMessages;
46 | saveStoredSetting(
47 | 'settings.hideUnverifiedMessages',
48 | state.hideUnverifiedMessages ? SETTINGS_OPTIONS.SHOW : SETTINGS_OPTIONS.HIDE,
49 | );
50 | },
51 | toggleSettings(state) {
52 | state.isOpen = !state.isOpen;
53 | },
54 | openSettings(state) {
55 | state.isOpen = true;
56 | },
57 | closeSettings(state) {
58 | state.isOpen = false;
59 | },
60 | },
61 | });
62 |
63 | export const { toggleSettings, toggleHideUnverifiedMessages, closeSettings, openSettings } =
64 | settingsSlice.actions;
65 |
66 | export default settingsSlice.reducer;
67 |
--------------------------------------------------------------------------------
/src/components/icons/HandcashIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // Handcash Green: #1CF567 #38CB7C
4 | const HandcashIcon = (props) => (
5 |
43 | );
44 |
45 | export default HandcashIcon;
46 |
--------------------------------------------------------------------------------
/tests/e2e/styling.spec.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '@playwright/test';
2 |
3 | test.describe('Styling and Typography', () => {
4 | test('should have consistent font sizes across components', async ({
5 | page,
6 | }) => {
7 | await page.goto('/');
8 |
9 | // Get all text elements
10 | const textElements = await page.locator('*:visible').all();
11 | const fontSizes = new Set();
12 |
13 | for (const element of textElements) {
14 | const fontSize = await element
15 | .evaluate((el) => window.getComputedStyle(el).fontSize)
16 | .catch(() => null);
17 |
18 | if (fontSize) {
19 | fontSizes.add(fontSize);
20 | }
21 | }
22 |
23 | // Should have a limited set of font sizes (good typography system)
24 | expect(fontSizes.size).toBeLessThanOrEqual(8);
25 | });
26 |
27 | test('should have proper contrast ratios', async ({ page }) => {
28 | await page.goto('/');
29 |
30 | // Check background and text color contrast
31 | const body = page.locator('body');
32 | const bgColor = await body.evaluate(
33 | (el) => window.getComputedStyle(el).backgroundColor,
34 | );
35 |
36 | // Should have a dark theme by default
37 | expect(bgColor).toMatch(/rgb\(\d+, \d+, \d+\)/);
38 | });
39 |
40 | test('buttons should have consistent styling', async ({ page }) => {
41 | await page.goto('/');
42 |
43 | const buttons = await page.locator('button').all();
44 | const buttonStyles = [];
45 |
46 | for (const button of buttons) {
47 | const styles = await button.evaluate((el) => ({
48 | padding: window.getComputedStyle(el).padding,
49 | borderRadius: window.getComputedStyle(el).borderRadius,
50 | fontWeight: window.getComputedStyle(el).fontWeight,
51 | }));
52 | buttonStyles.push(styles);
53 | }
54 |
55 | // Check that button styles are consistent
56 | expect(buttonStyles.length).toBeGreaterThan(0);
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/src/utils/backupDecryptor.ts:
--------------------------------------------------------------------------------
1 | import { decryptBackup as decryptBitcoinBackup } from 'bitcoin-backup';
2 | import { type BackupDetectionResult, detectBackupType } from './backupDetector';
3 |
4 | export interface DecryptionResult {
5 | success: boolean;
6 | result?: BackupDetectionResult;
7 | error?: string;
8 | needsPassword?: boolean;
9 | }
10 |
11 | /**
12 | * Attempts to decrypt an encrypted backup with a password
13 | */
14 | export async function decryptBackup(
15 | encryptedData: string,
16 | password: string,
17 | ): Promise {
18 | try {
19 | const decrypted = await decryptBitcoinBackup(encryptedData, password);
20 |
21 | // Now detect the type of the decrypted data
22 | const detectionResult = detectBackupType(JSON.stringify(decrypted));
23 |
24 | return {
25 | success: true,
26 | result: detectionResult,
27 | };
28 | } catch (error) {
29 | return {
30 | success: false,
31 | error: error instanceof Error ? error.message : 'Decryption failed',
32 | };
33 | }
34 | }
35 |
36 | /**
37 | * Process a backup file content - handles both encrypted and decrypted formats
38 | */
39 | export async function processBackupFile(
40 | content: string,
41 | password?: string,
42 | ): Promise {
43 | // First detect the backup type
44 | const detection = detectBackupType(content);
45 |
46 | if (detection.isEncrypted) {
47 | // Encrypted backup - needs password
48 | if (!password) {
49 | return {
50 | success: false,
51 | needsPassword: true,
52 | error: 'Password required for encrypted backup',
53 | };
54 | }
55 |
56 | // If detection.data is an object (encrypted backup format), convert to string
57 | const encryptedString =
58 | typeof detection.data === 'string' ? detection.data : JSON.stringify(detection.data);
59 | return decryptBackup(encryptedString, password);
60 | }
61 |
62 | // Not encrypted, return as-is
63 | return {
64 | success: true,
65 | result: detection,
66 | };
67 | }
68 |
--------------------------------------------------------------------------------
/src/context/theme.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, type ReactNode, useContext, useEffect, useState } from 'react';
2 |
3 | type Theme = 'light' | 'dark';
4 |
5 | interface ThemeContextType {
6 | theme: Theme;
7 | setTheme: (theme: Theme) => void;
8 | toggleTheme: () => void;
9 | }
10 |
11 | const ThemeContext = createContext(undefined);
12 |
13 | const THEME_STORAGE_KEY = 'bitchat-theme';
14 |
15 | function getSystemTheme(): Theme {
16 | if (typeof window === 'undefined') return 'dark';
17 | return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
18 | }
19 |
20 | function getInitialTheme(): Theme {
21 | if (typeof window === 'undefined') return 'dark';
22 | const stored = localStorage.getItem(THEME_STORAGE_KEY) as Theme | null;
23 | // If no stored preference, detect system preference
24 | if (!stored) {
25 | return getSystemTheme();
26 | }
27 | return stored;
28 | }
29 |
30 | export function ThemeProvider({ children }: { children: ReactNode }) {
31 | const [theme, setThemeState] = useState(getInitialTheme);
32 |
33 | useEffect(() => {
34 | const root = window.document.documentElement;
35 |
36 | if (theme === 'dark') {
37 | root.classList.add('dark');
38 | } else {
39 | root.classList.remove('dark');
40 | }
41 |
42 | // Persist the choice
43 | localStorage.setItem(THEME_STORAGE_KEY, theme);
44 | }, [theme]);
45 |
46 | const setTheme = (newTheme: Theme) => {
47 | setThemeState(newTheme);
48 | };
49 |
50 | const toggleTheme = () => {
51 | setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
52 | };
53 |
54 | return (
55 |
56 | {children}
57 |
58 | );
59 | }
60 |
61 | export function useTheme() {
62 | const context = useContext(ThemeContext);
63 | if (context === undefined) {
64 | throw new Error('useTheme must be used within a ThemeProvider');
65 | }
66 | return context;
67 | }
68 |
--------------------------------------------------------------------------------
/tests/visual/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test';
2 |
3 | export default defineConfig({
4 | testDir: './',
5 | fullyParallel: true,
6 | forbidOnly: !!process.env.CI,
7 | retries: process.env.CI ? 2 : 0,
8 | workers: process.env.CI ? 1 : undefined,
9 | reporter: [
10 | ['html', { outputFolder: 'visual-report' }],
11 | ['json', { outputFile: 'visual-results.json' }],
12 | ],
13 |
14 | // Global test options
15 | use: {
16 | baseURL: 'http://localhost:5176',
17 | trace: 'on-first-retry',
18 | video: 'retain-on-failure',
19 | screenshot: 'only-on-failure',
20 | },
21 |
22 | // Configure projects for major browsers
23 | projects: [
24 | {
25 | name: 'chromium-visual',
26 | use: {
27 | ...devices['Desktop Chrome'],
28 | // Ensure consistent rendering
29 | viewport: { width: 1400, height: 900 },
30 | deviceScaleFactor: 1,
31 | hasTouch: false,
32 | colorScheme: 'dark',
33 | },
34 | },
35 |
36 | // Uncomment for cross-browser testing
37 | // {
38 | // name: 'firefox-visual',
39 | // use: {
40 | // ...devices['Desktop Firefox'],
41 | // viewport: { width: 1400, height: 900 },
42 | // },
43 | // },
44 |
45 | // {
46 | // name: 'webkit-visual',
47 | // use: {
48 | // ...devices['Desktop Safari'],
49 | // viewport: { width: 1400, height: 900 },
50 | // },
51 | // },
52 | ],
53 |
54 | // Configure visual comparison
55 | expect: {
56 | // More lenient for visual tests due to font rendering differences
57 | toHaveScreenshot: {
58 | maxDiffPixels: 100,
59 | animations: 'disabled' as const,
60 | scale: 'css' as const,
61 | },
62 | toMatchSnapshot: {
63 | maxDiffPixels: 100,
64 | },
65 | },
66 |
67 | // Web server for local development
68 | webServer: {
69 | command: 'cd ../../ && bun run dev',
70 | url: 'http://localhost:5176',
71 | reuseExistingServer: !process.env.CI,
72 | timeout: 30000,
73 | },
74 | });
75 |
--------------------------------------------------------------------------------
/src/components/ui/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | function TooltipProvider({
7 | delayDuration = 0,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
16 | )
17 | }
18 |
19 | function Tooltip({
20 | ...props
21 | }: React.ComponentProps) {
22 | return (
23 |
24 |
25 |
26 | )
27 | }
28 |
29 | function TooltipTrigger({
30 | ...props
31 | }: React.ComponentProps) {
32 | return
33 | }
34 |
35 | function TooltipContent({
36 | className,
37 | sideOffset = 0,
38 | children,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
43 |
52 | {children}
53 |
54 |
55 |
56 | )
57 | }
58 |
59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
60 |
--------------------------------------------------------------------------------
/src/components/ui/List.jsx:
--------------------------------------------------------------------------------
1 | import { BsPin, BsPinFill } from 'react-icons/bs';
2 | import styled from 'styled-components';
3 |
4 | const Wrapper = styled.div`
5 | display: flex;
6 | flex-direction: ${(p) => (p.$horizontal ? 'row' : 'column')};
7 | gap: ${(p) => p.$gap};
8 | `;
9 |
10 | const ListItemContainer = styled.div`
11 | padding: 6px;
12 | margin: 1px 0;
13 | border-radius: 4px;
14 | display: flex;
15 | align-items: center;
16 | cursor: pointer;
17 | color: var(--muted-foreground);
18 | opacity: ${(props) => (props.$hasActivity ? 1 : 0.6)};
19 | background-color: ${(props) =>
20 | props.$isActive ? 'var(--accent)' : 'transparent'};
21 | &:hover {
22 | background-color: var(--accent);
23 | color: var(--foreground);
24 | opacity: 1;
25 | }
26 | `;
27 |
28 | const PinButton = styled.button`
29 | margin-left: auto;
30 | padding: 4px;
31 | color: var(--muted-foreground);
32 | &:hover {
33 | color: var(--foreground);
34 | }
35 | `;
36 |
37 | const _ListItem = ({
38 | icon,
39 | text,
40 | style,
41 | id,
42 | isPinned,
43 | onMouseEnter,
44 | onMouseLeave,
45 | hasActivity,
46 | isActive,
47 | showPin,
48 | onClickPin,
49 | ...props
50 | }) => {
51 | return (
52 |
61 | {icon}
62 | {text}
63 | {showPin && (
64 | {
66 | e.preventDefault();
67 | e.stopPropagation();
68 | onClickPin();
69 | }}
70 | type="button"
71 | >
72 | {isPinned ? : }
73 |
74 | )}
75 |
76 | );
77 | };
78 |
79 | const List = ({ horizontal, gap, children, ...delegated }) => {
80 | return (
81 |
82 | {children}
83 |
84 | );
85 | };
86 |
87 | export default List;
88 |
--------------------------------------------------------------------------------
/src/components/authForm/BigBlocksLoginPage.tsx.disabled:
--------------------------------------------------------------------------------
1 | import { BitcoinAuthProvider, LoginForm } from 'bigblocks';
2 | import type { FC } from 'react';
3 | import { useNavigate } from 'react-router-dom';
4 | import { useAppDispatch } from '../../hooks';
5 | import { loadChannels } from '../../reducers/channelsReducer';
6 | import { setYoursUser } from '../../reducers/sessionReducer';
7 | import Layout from './Layout';
8 |
9 | interface AuthUser {
10 | id: string;
11 | address: string;
12 | idKey: string;
13 | profiles?: unknown[];
14 | }
15 |
16 | interface AuthError {
17 | code: string;
18 | message: string;
19 | }
20 |
21 | export const BigBlocksLoginPage: FC = () => {
22 | const dispatch = useAppDispatch();
23 | const navigate = useNavigate();
24 |
25 | const handleSuccess = async (user: AuthUser) => {
26 | // Set user in Redux store using setYoursUser action
27 | dispatch(
28 | setYoursUser({
29 | paymail: user.address, // Use Bitcoin address as paymail for now
30 | address: user.address,
31 | }),
32 | );
33 |
34 | // Load channels and navigate to dashboard
35 | try {
36 | await dispatch(loadChannels()).unwrap();
37 | navigate('/channels/nitro');
38 | } catch (error) {
39 | console.error('Failed to load channels:', error);
40 | navigate('/channels/nitro'); // Navigate anyway
41 | }
42 | };
43 |
44 | const handleError = (error: AuthError) => {
45 | console.error('BigBlocks login failed:', error);
46 | // Handle specific error types as needed
47 | };
48 |
49 | return (
50 |
51 |
52 |
65 |
66 |
67 | );
68 | };
69 |
70 | export default BigBlocksLoginPage;
71 |
--------------------------------------------------------------------------------
/.cursorrules:
--------------------------------------------------------------------------------
1 | # ABILITY: Self Learning
2 | As you learn key facts about the project, update .cursorrules with new rules.
3 | # Bun
4 | use bun always instead of npm or yarn
5 |
6 | # Strategy
7 | - Do not assume, CHECK!
8 | - modern is better, simple is better
9 | - work on specifically the task requested
10 | - make suggestions of improvements, but dont implement them without confirmation
11 |
12 | # Last Known Good State
13 | Last known good commit: f765eefcf95e090b13fa36cce2f5c36ea2c7bba7
14 | - frequently check the last known good state of files at the provided commit hash to make sure we haven't broken anything
15 |
16 | You can query files from the last known good state using:
17 | 1. View file contents: `git show f765eefcf95e090b13fa36cce2f5c36ea2c7bba7:path/to/file | cat`
18 | 2. List changed files: `git diff --name-only f765eefcf95e090b13fa36cce2f5c36ea2c7bba7`
19 | 3. View specific changes: `git diff f765eefcf95e090b13fa36cce2f5c36ea2c7bba7 path/to/file`
20 | 4. Check file history: `git log -p f765eefcf95e090b13fa36cce2f5c36ea2c7bba7 path/to/file`
21 |
22 | # Linting
23 | bun lint
24 |
25 | # BSV Libraries
26 | npm: use @bsv/sdk instead of bsv
27 | npm: use bsv-bap for BAP functionality
28 |
29 | # Code rules
30 | - use bun instead of npm or yarn
31 | - use regular fetch for outside of components, tanstack query when inside components and never axios
32 | - use for loops instead of forEach
33 | - react: never import * as React from 'react';
34 | - react: dont import React just to use it as a type: React.FC - instead just import FC
35 | - only import type unless you need to use it as a value
36 |
37 | # Reviewing Target Commit
38 | When reviewing code from target commit f765eefcf95e090b13fa36cce2f5c36ea2c7bba7:
39 | 1. Use `git show f765eefcf95e090b13fa36cce2f5c36ea2c7bba7:path/to/file` to view old file versions
40 | 2. Check both .js and .tsx/.ts extensions as files may have been renamed
41 | 3. Compare implementations to restore critical functionality
42 | 4. Focus on core features: authentication, channels, messaging
43 | 5. Note key differences in state management and API handling
44 |
45 | # Auth
46 | - handcash returns from oauth with something like this: https://bitchatnitro.com/?authToken=
47 | - we extract the token from the url and use it to login
48 |
--------------------------------------------------------------------------------
/BACKEND_FIX_VALIDATION.md:
--------------------------------------------------------------------------------
1 | # Backend Validation Error Fix
2 |
3 | ## Current Issue
4 | The API is returning a validation error because some messages have `AIP` arrays without `address` fields. The `AIP` field is important for associating messages with signers.
5 |
6 | ## Solution
7 | Make `AIP.address` optional in validation schema since not all messages have this field.
8 |
9 | ### Why AIP is Important
10 | The frontend uses `AIP` to:
11 | 1. **For posts/messages**: Match `AIP[0].address` with `signer.currentAddress` from the signers array
12 | 2. **For DMs**: Use `AIP[0].bapId` to identify the sender
13 | 3. **For friend requests**: Use `AIP[0].bapId` to handle friend relationships
14 |
15 | The association works like this:
16 | - Message has `AIP[0].address` → Find signer with matching `currentAddress` → Display signer info
17 |
18 | ## What the Frontend Actually Uses
19 |
20 | From each message, the frontend accesses:
21 | 1. **Transaction ID**: `tx.h` or `txid`
22 | 2. **Metadata**: `MAP[0]` containing:
23 | - `paymail`
24 | - `type`
25 | - `context`
26 | - `channel`
27 | - `bapID`
28 | - `encrypted` (optional)
29 | - `messageID` (optional)
30 | 3. **Content**: `B[0].Data.utf8` or `content`
31 | 4. **Timestamp**: `timestamp` or `blk.t`
32 | 5. **Author Identity**: `AIP[0].address` (matched with `signer.currentAddress` from signers array)
33 |
34 | ## Recommended Fix
35 | Update the validation schema to make `AIP[0].address` optional:
36 |
37 | ```typescript
38 | // Update the AIP type definition
39 | interface AIP {
40 | bapId?: string; // Required for DMs and friend requests
41 | address?: string; // Required for posts - matches signer.currentAddress
42 | signature?: string; // Optional
43 | }
44 | ```
45 |
46 | ## Alternative: Clean AIP Arrays
47 | If you prefer to keep strict validation, ensure all AIP entries have at least empty address:
48 |
49 | ```javascript
50 | // Ensure AIP has address field (even if empty)
51 | messages.map(msg => ({
52 | ...msg,
53 | AIP: msg.AIP?.map(aip => ({
54 | bapId: aip.bapId,
55 | address: aip.address || '', // Default to empty string
56 | ...aip
57 | }))
58 | }))
59 | ```
60 |
61 | ## Keep These Fields
62 | - `AIP` - Required for author identification
63 | - `in` and `out` - Can be removed to reduce payload size
64 | - All other existing fields should remain
--------------------------------------------------------------------------------
/src/design/mixins.ts:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 |
3 | export const hideInDesktop = css`
4 | @media (min-width: 768px) {
5 | display: none;
6 | }
7 | `;
8 |
9 | export const interactiveColor = css`
10 | color: var(--muted-foreground);
11 | &:hover {
12 | color: var(--foreground);
13 | }
14 | &:active {
15 | color: var(--primary);
16 | }
17 | `;
18 |
19 | export const textLink = css`
20 | color: var(--primary);
21 | &:hover {
22 | text-decoration: underline;
23 | }
24 | `;
25 |
26 | export const scrollbar = css`
27 | ::-webkit-scrollbar {
28 | width: 16px;
29 | height: 16px;
30 | }
31 |
32 | ::-webkit-scrollbar-corner {
33 | background-color: transparent;
34 | }
35 |
36 | ::-webkit-scrollbar-thumb {
37 | background-color: var(--scrollbar-thin-thumb);
38 | border: 4px solid transparent;
39 | border-radius: 8px;
40 | min-height: 40px;
41 | background-clip: padding-box;
42 | }
43 |
44 | ::-webkit-scrollbar-track {
45 | border: 4px solid transparent;
46 | background-clip: padding-box;
47 | background-color: var(--scrollbar-thin-track);
48 | border-radius: 8px;
49 | }
50 |
51 | ::-webkit-scrollbar-thumb:hover {
52 | background-color: var(--scrollbar-thin-thumb-hover);
53 | }
54 | `;
55 |
56 | export const scrollbarLight = css`
57 | ::-webkit-scrollbar {
58 | width: 16px;
59 | height: 16px;
60 | }
61 |
62 | ::-webkit-scrollbar-corner {
63 | background-color: transparent;
64 | }
65 |
66 | ::-webkit-scrollbar-thumb {
67 | background-color: var(--scrollbar-auto-thumb);
68 | border: 4px solid transparent;
69 | border-radius: 8px;
70 | min-height: 40px;
71 | background-clip: padding-box;
72 | }
73 |
74 | ::-webkit-scrollbar-track {
75 | border: 4px solid transparent;
76 | background-clip: padding-box;
77 | background-color: var(--scrollbar-auto-track);
78 | border-radius: 8px;
79 | }
80 |
81 | ::-webkit-scrollbar-thumb:hover {
82 | background-color: var(--scrollbar-auto-thumb-hover);
83 | }
84 | `;
85 |
86 | export const baseIcon = css`
87 | display: flex;
88 | align-items: center;
89 | justify-content: center;
90 | width: 24px;
91 | height: 24px;
92 | `;
93 |
94 | export const roundedBackground = css`
95 | background-color: var(--muted);
96 | border-radius: 50%;
97 | `;
98 |
--------------------------------------------------------------------------------
/src/components/dashboard/UserPanel.tsx:
--------------------------------------------------------------------------------
1 | import { Settings } from 'lucide-react';
2 | import type { FC } from 'react';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import { useNavigate } from 'react-router-dom';
5 | import { Button } from '@/components/ui/button';
6 | import { logout } from '../../reducers/sessionReducer';
7 | import { toggleSettings } from '../../reducers/settingsReducer';
8 | import type { RootState } from '../../store';
9 | import Avatar from './Avatar';
10 |
11 | const UserPanel: FC = () => {
12 | const dispatch = useDispatch();
13 | const navigate = useNavigate();
14 | const user = useSelector((state: RootState) => state.session.user);
15 |
16 | const _handleSignOut = async () => {
17 | dispatch(logout());
18 | navigate('/');
19 | };
20 |
21 | const handleSettingsClick = () => {
22 | dispatch(toggleSettings());
23 | };
24 |
25 | // Display name with paymail as fallback
26 | const displayName = user?.displayName || user?.paymail || 'Guest';
27 |
28 | return (
29 |
30 |
31 |
32 |
33 |
34 | {displayName}
35 |
36 |
37 | {user?.wallet === 'sigma'
38 | ? 'Sigma Identity'
39 | : user?.wallet === 'yours'
40 | ? 'Yours Wallet'
41 | : user?.wallet === 'handcash'
42 | ? 'HandCash'
43 | : user?.wallet
44 | ? user.wallet
45 | : 'Not connected'}
46 |
47 |
48 |
49 |
50 |
51 |
60 |
61 |
62 | );
63 | };
64 |
65 | export default UserPanel;
66 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
15 | outline:
16 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost:
20 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
27 | icon: "size-9",
28 | "icon-sm": "size-8",
29 | "icon-lg": "size-10",
30 | },
31 | },
32 | defaultVariants: {
33 | variant: "default",
34 | size: "default",
35 | },
36 | }
37 | )
38 |
39 | function Button({
40 | className,
41 | variant,
42 | size,
43 | asChild = false,
44 | ...props
45 | }: React.ComponentProps<"button"> &
46 | VariantProps & {
47 | asChild?: boolean
48 | }) {
49 | const Comp = asChild ? Slot : "button"
50 |
51 | return (
52 |
57 | )
58 | }
59 |
60 | export { Button, buttonVariants }
61 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Card({ className, ...props }: React.ComponentProps<"div">) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
19 | return (
20 |
28 | )
29 | }
30 |
31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
32 | return (
33 |
38 | )
39 | }
40 |
41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
42 | return (
43 |
48 | )
49 | }
50 |
51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52 | return (
53 |
61 | )
62 | }
63 |
64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) {
65 | return (
66 |
71 | )
72 | }
73 |
74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
75 | return (
76 |
81 | )
82 | }
83 |
84 | export {
85 | Card,
86 | CardHeader,
87 | CardFooter,
88 | CardTitle,
89 | CardAction,
90 | CardDescription,
91 | CardContent,
92 | }
93 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | import type { MouseEvent } from 'react';
2 | import { useLayoutEffect, useState } from 'react';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import type { AppDispatch, RootState } from '../store';
5 |
6 | // Redux hooks
7 | export const useAppDispatch = () => useDispatch();
8 | export const useAppSelector = (selector: (state: RootState) => T): T =>
9 | useSelector(selector);
10 |
11 | // Window hooks
12 | export const useWindowWidth = () => {
13 | const [width, setWidth] = useState(0);
14 | useLayoutEffect(() => {
15 | function updateSize() {
16 | setWidth(window.innerWidth);
17 | }
18 | window.addEventListener('resize', updateSize);
19 | updateSize();
20 | return () => window.removeEventListener('resize', updateSize);
21 | }, []);
22 | return width;
23 | };
24 |
25 | // Channel hooks
26 | export const useActiveChannel = () => {
27 | const loading = useAppSelector((state) => state.channels.loading);
28 | const activeChannelId = useAppSelector((state) => state.channels.active);
29 | const channelsById = useAppSelector((state) => state.channels.byId);
30 | return (!loading && activeChannelId && channelsById[activeChannelId]) || false;
31 | };
32 |
33 | // User hooks
34 | export const useActiveUserOld = () => {
35 | const loading = useAppSelector((state) => state.memberList.loading);
36 | const activeChannelId = useAppSelector((state) => state.channels.active);
37 | const activeUserId = useAppSelector((state) => state.memberList.active);
38 | const usersById = useAppSelector((state) => state.memberList.byId);
39 | return (!activeChannelId && !loading && activeUserId && usersById[activeUserId]) || false;
40 | };
41 |
42 | // UI hooks
43 | export const usePopover = () => {
44 | const [showPopover, setShowPopover] = useState(false);
45 | const [user, setUser] = useState('');
46 | const [anchorEl, setAnchorEl] = useState(null);
47 |
48 | const handleClick = (event: MouseEvent, userId: string) => {
49 | setUser(userId);
50 | setShowPopover(true);
51 | setAnchorEl(event.currentTarget);
52 | };
53 |
54 | const handleClickAway = () => {
55 | setUser('');
56 | setShowPopover(false);
57 | setAnchorEl(null);
58 | };
59 |
60 | return [user, anchorEl, showPopover, setShowPopover, handleClick, handleClickAway] as const;
61 | };
62 |
63 | // Export other hook files
64 | export * from './useActiveUser';
65 |
--------------------------------------------------------------------------------
/public/images/YoursIcon.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | // Common interfaces used across components
2 |
3 | export interface User {
4 | paymail?: string;
5 | wallet?: string;
6 | authToken?: string;
7 | idKey?: string;
8 | icon?: string;
9 | address?: string;
10 | }
11 |
12 | export interface Channel {
13 | channel: string;
14 | last_message: string;
15 | last_message_time: number;
16 | messages: number;
17 | creator: string;
18 | }
19 |
20 | export interface Message {
21 | tx: {
22 | h: string;
23 | };
24 | MAP: {
25 | type: string;
26 | channel?: string;
27 | context?: string;
28 | bapID?: string;
29 | paymail?: string;
30 | emoji?: string;
31 | }[];
32 | B: {
33 | content: string;
34 | }[];
35 | AIP: {
36 | bapId: string;
37 | }[];
38 | timestamp: number;
39 | }
40 |
41 | export interface FriendRequest {
42 | MAP: {
43 | bapID: string;
44 | publicKey: string;
45 | }[];
46 | }
47 |
48 | export interface Server {
49 | id: string;
50 | name: string;
51 | icon?: string;
52 | }
53 |
54 | export interface EmojiClickData {
55 | emoji: string;
56 | names: string[];
57 | originalUnified: string;
58 | unified: string;
59 | }
60 |
61 | // Redux state interfaces
62 | export interface RootState {
63 | session: {
64 | user: User | null;
65 | loading: boolean;
66 | error: string | null;
67 | isAuthenticated: boolean;
68 | };
69 | channels: ChannelsState;
70 | chat: ChatState;
71 | memberList: MemberListState;
72 | servers: ServersState;
73 | profile: {
74 | isOpen: boolean;
75 | };
76 | }
77 |
78 | export interface SessionState {
79 | user: User | null;
80 | loading: boolean;
81 | error: string | null;
82 | }
83 |
84 | export interface ChannelsState {
85 | channels: Channel[];
86 | loading: boolean;
87 | error: string | null;
88 | }
89 |
90 | export interface ChatState {
91 | messages: Message[];
92 | loading: boolean;
93 | error: string | null;
94 | }
95 |
96 | export interface MemberListState {
97 | users: User[];
98 | friendRequests: {
99 | incoming: {
100 | allIds: string[];
101 | byId: Record;
102 | };
103 | outgoing: {
104 | allIds: string[];
105 | byId: Record;
106 | };
107 | };
108 | loading: boolean;
109 | error: string | null;
110 | }
111 |
112 | export interface ServersState {
113 | servers: Server[];
114 | loading: boolean;
115 | error: string | null;
116 | }
117 |
--------------------------------------------------------------------------------
/src/components/dashboard/ListItem.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { AiFillPushpin } from 'react-icons/ai';
3 |
4 | import styled, { css } from 'styled-components';
5 |
6 | const Text = styled.span`
7 | font-weight: 500;
8 | ${(p) =>
9 | `font-size: ${p.textStyle?.fontSize ? p.textStyle.fontSize : '15px'}`};
10 | ${(p) => `color: ${p.$isActive ? 'var(--foreground)' : 'var(--muted-foreground)'}`};
11 | white-space: nowrap;
12 | text-overflow: ellipsis;
13 | overflow: hidden;
14 | `;
15 |
16 | const Icon = styled.div`
17 | color: var(--muted-foreground);
18 | &:hover {
19 | color: var(--primary);
20 | }
21 | `;
22 |
23 | const Wrapper = styled.div`
24 | flex: 1;
25 | display: flex;
26 | align-items: center;
27 | justify-content: space-between;
28 | `;
29 |
30 | const Container = styled.div`
31 | display: flex;
32 | align-items: center;
33 | border-radius: 4px;
34 |
35 | &:hover {
36 | background-color: var(--accent);
37 | cursor: pointer;
38 | }
39 |
40 | &:hover > ${Text} {
41 | color: var(--foreground);
42 | }
43 | ${(p) =>
44 | p.$isPinned &&
45 | css`
46 | border: 1px solid gold;
47 | `}
48 | ${(p) =>
49 | p.$isActive &&
50 | css`
51 | &,
52 | &:hover {
53 | background-color: var(--accent);
54 | }
55 |
56 | & > ${Text}, &:hover > ${Text} {
57 | color: var(--primary);
58 | }
59 | `}
60 | `;
61 |
62 | const ListItem = ({
63 | icon,
64 | text,
65 | isPinned,
66 | showPin,
67 | isActive,
68 | hasActivity,
69 | onClickPin,
70 | textStyle,
71 | ...delegated
72 | }) => {
73 | return (
74 |
75 | {icon}
76 |
77 | {text && (
78 |
83 | {text}
84 |
85 | )}
86 | {!isPinned && showPin && (
87 | {
89 | e.stopPropagation();
90 | e.preventDefault();
91 | onClickPin();
92 | }}
93 | >
94 |
95 |
96 | )}
97 |
98 |
99 | );
100 | };
101 |
102 | export default ListItem;
103 |
--------------------------------------------------------------------------------
/src/hooks/useOwnedThemes.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * useOwnedThemes Hook
3 | *
4 | * Fetches user's owned ThemeToken NFTs from sigma API.
5 | * Filters NFTs by MAP.app === 'ThemeToken' to identify theme tokens.
6 | */
7 |
8 | import { useCallback, useEffect, useState } from 'react';
9 | import { useSelector } from 'react-redux';
10 | import { SIGMA_AUTH_URL } from '../config/constants';
11 | import type { RootState } from '../store';
12 |
13 | export interface OwnedTheme {
14 | origin: string;
15 | name?: string;
16 | author?: string;
17 | }
18 |
19 | interface UseOwnedThemesResult {
20 | themes: OwnedTheme[];
21 | addresses: string[];
22 | loading: boolean;
23 | error: string | null;
24 | refresh: () => Promise;
25 | }
26 |
27 | export function useOwnedThemes(): UseOwnedThemesResult {
28 | const session = useSelector((state: RootState) => state.session);
29 | const [themes, setThemes] = useState([]);
30 | const [addresses, setAddresses] = useState([]);
31 | const [loading, setLoading] = useState(false);
32 | const [error, setError] = useState(null);
33 |
34 | const accessToken =
35 | typeof localStorage !== 'undefined' ? localStorage.getItem('sigma_access_token') : null;
36 |
37 | const fetchThemes = useCallback(async () => {
38 | if (!accessToken || !session.isAuthenticated) {
39 | setThemes([]);
40 | setAddresses([]);
41 | return;
42 | }
43 |
44 | setLoading(true);
45 | setError(null);
46 |
47 | try {
48 | const response = await fetch(`${SIGMA_AUTH_URL}/api/wallet/nfts`, {
49 | headers: { Authorization: `Bearer ${accessToken}` },
50 | });
51 |
52 | if (response.ok) {
53 | const data = await response.json();
54 | // Filter for theme NFTs (map.type === 'theme')
55 | const themeTokens =
56 | data.nfts?.filter((nft: { map?: { type?: string } }) => nft.map?.type === 'theme') || [];
57 | setThemes(themeTokens);
58 | setAddresses(data.addresses || []);
59 | } else if (response.status === 404) {
60 | // Endpoint not implemented yet
61 | setThemes([]);
62 | setAddresses([]);
63 | } else {
64 | setThemes([]);
65 | setAddresses([]);
66 | }
67 | } catch (err) {
68 | setError(err instanceof Error ? err.message : 'Failed to fetch themes');
69 | setThemes([]);
70 | } finally {
71 | setLoading(false);
72 | }
73 | }, [accessToken, session.isAuthenticated]);
74 |
75 | useEffect(() => {
76 | fetchThemes();
77 | }, [fetchThemes]);
78 |
79 | return { themes, addresses, loading, error, refresh: fetchThemes };
80 | }
81 |
--------------------------------------------------------------------------------
/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react';
2 | import { Suspense } from 'react';
3 | import { createBrowserRouter } from 'react-router-dom';
4 | import { YoursProvider } from 'yours-wallet-provider';
5 | import ErrorBoundary from '../components/ErrorBoundary';
6 | import { BapProvider } from '../context/bap';
7 | import { BitcoinProvider } from '../context/bitcoin';
8 | import { BmapProvider } from '../context/bmap';
9 | import { HandcashProvider } from '../context/handcash';
10 | import { AutoYoursProvider } from '../context/yours';
11 | import * as LazyComponents from './lazyComponents';
12 |
13 | const LoadingSpinner = () => (
14 |
23 | Loading...
24 |
25 | );
26 |
27 | const withProviders = (element: ReactNode) => (
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | }>{element}
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 |
45 | const router = createBrowserRouter([
46 | {
47 | path: '/',
48 | element: withProviders(),
49 | },
50 | {
51 | path: '/login',
52 | element: withProviders(),
53 | },
54 | {
55 | path: '/signup',
56 | element: withProviders(),
57 | },
58 | {
59 | path: '/auth/sigma/callback',
60 | element: withProviders(),
61 | },
62 | {
63 | path: '/channels',
64 | element: withProviders(),
65 | },
66 | {
67 | path: '/friends',
68 | element: withProviders(),
69 | },
70 | {
71 | path: '/channels/:channel',
72 | element: withProviders(),
73 | },
74 | {
75 | path: '/@/:user',
76 | element: withProviders(),
77 | },
78 | {
79 | path: '/servers/new',
80 | element: withProviders(),
81 | },
82 | {
83 | path: '/servers/:serverId',
84 | element: withProviders(),
85 | },
86 | ]);
87 |
88 | export default router;
89 |
--------------------------------------------------------------------------------
/src/routes/lazyComponents.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // Auth components
4 | export const LoginPage = React.lazy(() =>
5 | import('../components/authForm/LoginPage').then((module) => ({
6 | default: module.LoginPage,
7 | })),
8 | );
9 |
10 | export const SignupPage = React.lazy(() => import('../components/authForm/SignupPage'));
11 |
12 | export const SigmaCallback = React.lazy(() =>
13 | import('../components/authForm/SigmaCallback').then((module) => ({
14 | default: module.SigmaCallback,
15 | })),
16 | );
17 |
18 | // Dashboard components
19 | export const Dashboard = React.lazy(() =>
20 | import('../components/dashboard/Dashboard').then((module) => ({
21 | default: module.Dashboard,
22 | })),
23 | );
24 |
25 | export const Friends = React.lazy(() =>
26 | import('../components/dashboard/Friends').then((module) => ({
27 | default: module.Friends,
28 | })),
29 | );
30 |
31 | export const UserSearch = React.lazy(() =>
32 | import('../components/dashboard/UserSearch').then((module) => ({
33 | default: module.UserSearch,
34 | })),
35 | );
36 |
37 | // Modals
38 | export const ImportIDModal = React.lazy(
39 | () => import('../components/dashboard/modals/ImportIDModal'),
40 | );
41 |
42 | export const DirectMessageModal = React.lazy(
43 | () => import('../components/dashboard/modals/DirectMessageModal'),
44 | );
45 |
46 | export const PinChannelModal = React.lazy(
47 | () => import('../components/dashboard/modals/PinChannelModal'),
48 | );
49 |
50 | // Lists
51 | export const ChannelList = React.lazy(() => import('../components/dashboard/ChannelList'));
52 |
53 | export const ServerList = React.lazy(() => import('../components/dashboard/ServerList'));
54 |
55 | export const UserList = React.lazy(() =>
56 | import('../components/dashboard/UserList').then((module) => ({
57 | default: module.UserList,
58 | })),
59 | );
60 |
61 | export const MemberList = React.lazy(() =>
62 | import('../components/dashboard/MemberList').then((module) => ({
63 | default: module.MemberList,
64 | })),
65 | );
66 |
67 | // Messages
68 | export const MessageFiles = React.lazy(() => import('../components/dashboard/MessageFiles'));
69 |
70 | export const FileRenderer = React.lazy(() => import('../components/dashboard/FileRenderer'));
71 |
72 | // Server Settings
73 | export const ServerSettings = React.lazy(() =>
74 | import('../components/ServerSettings').then((module) => ({
75 | default: module.ServerSettings,
76 | })),
77 | );
78 |
79 | export const NewServerPage = React.lazy(() =>
80 | import('../components/NewServerPage').then((module) => ({
81 | default: module.NewServerPage,
82 | })),
83 | );
84 |
--------------------------------------------------------------------------------
/tests/e2e/unhooked-features.spec.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '@playwright/test';
2 |
3 | test.describe('Unhooked Features', () => {
4 | test('should show settings modal when clicking settings', async ({
5 | page,
6 | }) => {
7 | await page.goto('/');
8 |
9 | // Navigate to a channel or login first
10 | await page.goto('/c/general');
11 |
12 | // Click settings icon
13 | const settingsButton = page.locator('[title="Settings"]');
14 | await expect(settingsButton).toBeVisible();
15 | await settingsButton.click();
16 |
17 | // Settings modal should appear
18 | const settingsModal = page.locator('dialog');
19 | await expect(settingsModal).toBeVisible();
20 | await expect(settingsModal.locator('h3')).toHaveText('Settings');
21 |
22 | // Should have the hide unverified messages toggle
23 | await expect(page.getByText('Hide unverified messages')).toBeVisible();
24 | });
25 |
26 | test('profile panel should show when clicking profile button', async ({
27 | page,
28 | }) => {
29 | await page.goto('/c/general');
30 |
31 | // Click profile button
32 | const profileButton = page.locator('[title*="Profile"]');
33 | if (await profileButton.isVisible()) {
34 | await profileButton.click();
35 |
36 | // Profile panel should appear
37 | const profilePanel = page.locator('text="Profile"');
38 | await expect(profilePanel).toBeVisible();
39 | }
40 | });
41 |
42 | test('server list should have add server button', async ({ page }) => {
43 | await page.goto('/c/general');
44 |
45 | // Check for add server button
46 | const addServerButton = page.locator('button[aria-label="Add Server"]');
47 | await expect(addServerButton).toBeVisible();
48 |
49 | // Click should navigate (but route doesn't exist)
50 | await addServerButton.click();
51 |
52 | // Should try to navigate to /servers/new
53 | await expect(page).toHaveURL(/\/servers\/new/);
54 | });
55 |
56 | test.skip('ImportIDModal should be accessible', async ({ page }) => {
57 | // This modal is implemented but never rendered
58 | // This test will fail until we hook it up
59 | await page.goto('/c/general');
60 |
61 | // There should be a way to import identity
62 | const importButton = page.getByText(/import.*identity/i);
63 | await expect(importButton).toBeVisible();
64 | });
65 |
66 | test.skip('DirectMessageModal should be accessible', async ({ page }) => {
67 | // This modal is implemented but never rendered
68 | // This test will fail until we hook it up
69 | await page.goto('/c/general');
70 |
71 | // There should be a way to start a DM
72 | const dmButton = page.getByText(/direct.*message/i);
73 | await expect(dmButton).toBeVisible();
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/src/components/login-form.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { Input } from '@/components/ui/input';
3 | import { Label } from '@/components/ui/label';
4 | import { cn } from '@/lib/utils';
5 |
6 | export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'form'>) {
7 | return (
8 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import react from '@vitejs/plugin-react';
3 | import { defineConfig } from 'vite';
4 | import macrosPlugin from 'vite-plugin-babel-macros';
5 |
6 | export default defineConfig({
7 | plugins: [
8 | react({
9 | babel: {
10 | plugins: [
11 | 'babel-plugin-macros',
12 | [
13 | 'babel-plugin-styled-components',
14 | {
15 | displayName: true,
16 | ssr: false,
17 | },
18 | ],
19 | ],
20 | },
21 | }),
22 | macrosPlugin(),
23 | ],
24 | resolve: {
25 | alias: {
26 | '@': path.resolve(__dirname, './src'),
27 | },
28 | extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'],
29 | },
30 | define: {
31 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
32 | },
33 | optimizeDeps: {
34 | esbuildOptions: {
35 | target: 'esnext',
36 | },
37 | },
38 | build: {
39 | target: 'esnext',
40 | outDir: 'build',
41 | sourcemap: true,
42 | chunkSizeWarningLimit: 500,
43 | rollupOptions: {
44 | output: {
45 | manualChunks(id) {
46 | // Core dependencies
47 | if (
48 | id.includes('node_modules/react/') ||
49 | id.includes('node_modules/react-dom/')
50 | ) {
51 | return 'react-core';
52 | }
53 |
54 | // Routing
55 | if (
56 | id.includes('node_modules/react-router') ||
57 | id.includes('node_modules/history/')
58 | ) {
59 | return 'routing';
60 | }
61 |
62 | // State management
63 | if (
64 | id.includes('node_modules/react-redux') ||
65 | id.includes('node_modules/@reduxjs/toolkit')
66 | ) {
67 | return 'redux';
68 | }
69 |
70 | // UI and styling
71 | if (id.includes('node_modules/styled-components')) {
72 | return 'styling';
73 | }
74 |
75 | // Bitcoin/BSV related
76 | if (
77 | id.includes('node_modules/@bsv/') ||
78 | id.includes('node_modules/bsv-bap/')
79 | ) {
80 | return 'bitcoin';
81 | }
82 |
83 | // Utils and polyfills
84 | if (
85 | id.includes('node_modules/buffer/') ||
86 | id.includes('node_modules/node-polyfills/')
87 | ) {
88 | return 'utils';
89 | }
90 | },
91 | // Optimize chunk distribution
92 | entryFileNames: 'assets/[name]-[hash].js',
93 | chunkFileNames: 'assets/[name]-[hash].js',
94 | assetFileNames: 'assets/[name]-[hash].[ext]',
95 | },
96 | },
97 | minify: 'esbuild',
98 | cssMinify: true,
99 | cssCodeSplit: true,
100 | },
101 | });
102 |
--------------------------------------------------------------------------------
/src/components/icons/YoursIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 |
3 | interface YoursIconProps {
4 | size?: string | number;
5 | className?: string;
6 | }
7 |
8 | const YoursIcon: FC = ({ size = '1em', className = '' }) => {
9 | const sizeStyle = {
10 | width: typeof size === 'number' ? `${size}px` : size,
11 | height: 'auto',
12 | };
13 |
14 | return (
15 |
30 | );
31 | };
32 |
33 | export default YoursIcon;
34 |
--------------------------------------------------------------------------------
/public/images/retrofeed_512_2.svg:
--------------------------------------------------------------------------------
1 |
39 |
--------------------------------------------------------------------------------
/public/images/logo-noBgColor.svg:
--------------------------------------------------------------------------------
1 |
2 |
9 |
--------------------------------------------------------------------------------
/src/utils/file.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Converts a file to base64 format.
3 | * @param {File} file - The file to be converted.
4 | * @returns {Promise} - A promise that resolves with the base64 representation of the file.
5 | */
6 | export const toB64 = (file) =>
7 | new Promise((resolve, reject) => {
8 | const reader = new FileReader();
9 | reader.onload = () => resolve(reader.result);
10 | reader.onerror = (error) => reject(error);
11 | reader.readAsDataURL(file);
12 | });
13 |
14 | /**
15 | * The maximum file size limit for file uploads.
16 | */
17 | export const UPLOAD_FILE_SIZE_LIMIT = 1024 * 50; // 50kb
18 |
19 | /**
20 | * Formats the given number of bytes into a human-readable string.
21 | * @param {number} bytes - The number of bytes.
22 | * @returns {string} - The formatted string representing the file size.
23 | */
24 | export function formatBytes(bytes) {
25 | if (bytes < 1024) {
26 | return `${bytes}B`;
27 | }
28 | if (bytes < 1024 * 1024) {
29 | return `${(bytes / 1024).toFixed(0)}KB`;
30 | }
31 | return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
32 | }
33 |
34 | /**
35 | * Checks if the given MIME type can be played by an audio or video element.
36 | * @param {string} mimeType - The MIME type to check.
37 | * @returns {boolean} - True if the MIME type can be played, false otherwise.
38 | */
39 | function canPlayType(mimeType) {
40 | if (!mimeType) {
41 | return false;
42 | }
43 |
44 | const elementType = mimeType.startsWith('audio')
45 | ? 'audio'
46 | : mimeType.startsWith('video')
47 | ? 'video'
48 | : null;
49 |
50 | if (!elementType) {
51 | return false;
52 | }
53 |
54 | const dummyMediaElement = document.createElement(elementType);
55 | if (!dummyMediaElement.canPlayType) {
56 | return false;
57 | }
58 |
59 | return !!dummyMediaElement.canPlayType(mimeType);
60 | }
61 |
62 | /**
63 | * Validates the given file based on its size and type.
64 | * @param {File} file - The file to validate.
65 | * @returns {object} - An object with the validation result and error message (if any).
66 | */
67 | export function validateFile(file) {
68 | if (file.size > UPLOAD_FILE_SIZE_LIMIT) {
69 | return {
70 | valid: false,
71 | error: 'File size exceeds the limit. Please choose a smaller file.',
72 | };
73 | }
74 |
75 | const isImage = file.type.startsWith('image');
76 | const isTypeSupported = isImage || canPlayType(file.type);
77 |
78 | if (!isTypeSupported) {
79 | return {
80 | valid: false,
81 | error: 'File type not supported. Please choose a different file.',
82 | };
83 | }
84 |
85 | return { valid: true, error: '' };
86 | }
87 |
88 | /**
89 | * Gets the base64 URL of a B file.
90 | * @param {object} bFile - The B file object containing the base64 data and content type.
91 | * @returns {string|null} - The base64 URL of the file, or null if the file object is invalid.
92 | */
93 | export function getBase64Url(bFile) {
94 | if (!bFile || !bFile['content-type']) {
95 | return null;
96 | }
97 |
98 | const b64Data = bFile.content;
99 | const contentType = bFile['content-type'];
100 |
101 | if (!b64Data || !contentType) {
102 | return null;
103 | }
104 |
105 | return `data:${contentType};base64,${b64Data}`;
106 | }
107 |
--------------------------------------------------------------------------------
/.claude/commands/review.md:
--------------------------------------------------------------------------------
1 | # Review Command - BitChat Nitro
2 |
3 | ## Purpose
4 | This command helps validate that implemented features actually work from a user perspective, not just pass tests.
5 |
6 | ## Pre-Implementation Checklist
7 |
8 | Before implementing any feature:
9 |
10 | 1. **Read the exact user request** - Don't add features not explicitly requested
11 | 2. **Identify the specific location** - Where exactly should changes be made?
12 | 3. **Understand the user journey** - What should the actual user experience be?
13 |
14 | ## Post-Implementation Validation
15 |
16 | After implementing features, ALWAYS:
17 |
18 | ### 1. Manual Testing Required
19 | - [ ] Start the development server (`bun run dev`)
20 | - [ ] Navigate to the actual feature in a browser
21 | - [ ] Click through the exact user flow that was requested
22 | - [ ] Verify the feature works as expected from a user perspective
23 |
24 | ### 2. Real User Experience Testing
25 | - [ ] Test with actual clicks, not just automated tests
26 | - [ ] Check for error messages or broken flows
27 | - [ ] Verify redirects work correctly
28 | - [ ] Test on localhost with real browser interaction
29 |
30 | ### 3. OAuth Flow Specific Checks
31 | - [ ] Click "Sign in with Bitcoin" button manually
32 | - [ ] Verify redirect to sigma-auth server works
33 | - [ ] Check that OAuth parameters are properly passed
34 | - [ ] Ensure callback handling works correctly
35 | - [ ] Test session persistence across page refreshes
36 |
37 | ### 4. API Integration Testing
38 | - [ ] Test with actual API endpoints, not mocked responses
39 | - [ ] Verify error handling with real network conditions
40 | - [ ] Check authentication headers and token handling
41 |
42 | ## Common Pitfalls to Avoid
43 |
44 | 1. **Don't assume tests passing = feature working**
45 | - Tests can pass while the actual user experience is broken
46 | - Always manually test the feature after implementation
47 |
48 | 2. **Don't add unrequested features**
49 | - Only implement exactly what the user asked for
50 | - Don't add "nice to have" features without explicit request
51 |
52 | 3. **Don't rely on URL patterns in tests**
53 | - Server redirects can change URL patterns
54 | - Focus on testing actual user flows, not implementation details
55 |
56 | 4. **Don't ignore console errors**
57 | - Check browser console for JavaScript errors
58 | - Verify network requests are successful
59 |
60 | ## Validation Commands
61 |
62 | ### Start Development Server
63 | ```bash
64 | bun run dev
65 | ```
66 |
67 | ### Test OAuth Flow Manually
68 | 1. Open browser to `http://localhost:5173` (or appropriate port)
69 | 2. Click "Sign in with Bitcoin"
70 | 3. Verify redirect to sigma-auth server
71 | 4. Check browser console for errors
72 | 5. Test callback handling
73 |
74 | ### Check for Broken Features
75 | ```bash
76 | # Build the project to catch compile errors
77 | bun run build
78 |
79 | # Run linter to catch code issues
80 | bun run lint
81 |
82 | # Run tests but also manually verify
83 | bun run test
84 | ```
85 |
86 | ## Questions to Ask Before Marking Complete
87 |
88 | 1. **Does the feature work when I click it in a browser?**
89 | 2. **Are there any console errors or warnings?**
90 | 3. **Does the user flow work end-to-end?**
91 | 4. **Did I implement exactly what was requested, nothing more?**
92 | 5. **Would a real user be able to use this feature successfully?**
93 |
94 | ## Remember
95 |
96 | **Tests passing ≠ Feature working**
97 |
98 | Always validate the actual user experience, not just the test results.
--------------------------------------------------------------------------------
/src/components/dashboard/ChannelList.tsx:
--------------------------------------------------------------------------------
1 | import { Hash, Plus } from 'lucide-react';
2 | import type React from 'react';
3 | import { useCallback, useEffect, useState } from 'react';
4 | import { useDispatch, useSelector } from 'react-redux';
5 | import { useNavigate, useParams } from 'react-router-dom';
6 | import {
7 | SidebarGroup,
8 | SidebarGroupAction,
9 | SidebarGroupContent,
10 | SidebarGroupLabel,
11 | SidebarMenu,
12 | SidebarMenuButton,
13 | SidebarMenuItem,
14 | } from '@/components/ui/sidebar';
15 | import { loadChannels } from '../../reducers/channelsReducer';
16 | import type { AppDispatch, RootState } from '../../store';
17 | import ErrorBoundary from '../ErrorBoundary';
18 | import DirectMessageModal from './modals/DirectMessageModal';
19 |
20 | export interface Channel {
21 | id?: string;
22 | channel: string;
23 | last_message_time?: number;
24 | last_message?: string;
25 | messages?: number;
26 | creator?: string;
27 | }
28 |
29 | const ChannelListContent: React.FC = () => {
30 | const dispatch = useDispatch();
31 | const navigate = useNavigate();
32 | const params = useParams();
33 | const [showDMModal, setShowDMModal] = useState(false);
34 |
35 | const { loading, channels } = useSelector((state: RootState) => {
36 | const { byId, allIds, loading } = state.channels;
37 | return {
38 | loading,
39 | channels: allIds.map((id) => byId[id]).filter(Boolean),
40 | };
41 | });
42 |
43 | useEffect(() => {
44 | dispatch(loadChannels());
45 | }, [dispatch]);
46 |
47 | const handleClick = useCallback(
48 | (channelId: string) => {
49 | navigate(`/channels/${channelId}`);
50 | },
51 | [navigate],
52 | );
53 |
54 | if (loading) {
55 | return (
56 |
57 | Text Channels
58 |
59 |
60 | Loading channels...
61 |
62 |
63 |
64 | );
65 | }
66 |
67 | return (
68 | <>
69 |
70 | Text Channels ({channels.length})
71 | setShowDMModal(true)} title="Start Direct Message">
72 |
73 |
74 |
75 |
76 | {!channels.length && (
77 |
78 | No channels found
79 |
80 | )}
81 | {channels.map((channel) => {
82 | const isActive = channel.channel === params.channel;
83 | return (
84 |
85 | handleClick(channel.channel)}
88 | tooltip={channel.channel}
89 | >
90 |
91 | {channel.channel}
92 |
93 |
94 | );
95 | })}
96 |
97 |
98 |
99 |
100 | >
101 | );
102 | };
103 |
104 | const ChannelListWrapper: React.FC = () => {
105 | return (
106 |
107 |
108 |
109 | );
110 | };
111 |
112 | export default ChannelListWrapper;
113 |
--------------------------------------------------------------------------------
/MESSAGE_FORMAT.md:
--------------------------------------------------------------------------------
1 | # Message Endpoint Expected Response Format
2 |
3 | The app expects the `/social/channels/{channelName}/messages` endpoint to return:
4 |
5 | ```typescript
6 | interface MessageResponse {
7 | results: Message[];
8 | signers?: Array<{
9 | idKey: string;
10 | paymail: string;
11 | logo?: string;
12 | isFriend?: boolean;
13 | }>;
14 | }
15 | ```
16 |
17 | ## Message Formats Supported
18 |
19 | The app handles two different message formats that can be in the `results` array:
20 |
21 | ### Format 1: Simple Message Object
22 | ```typescript
23 | interface Message {
24 | txid?: string;
25 | tx?: { h: string };
26 | paymail?: string;
27 | type?: string; // Usually "message"
28 | context?: string; // "channel" or "messageID"
29 | channel?: string; // Channel name
30 | messageID?: string; // For replies/reactions
31 | encrypted?: string; // "true" if encrypted
32 | bapID?: string; // Bitcoin Attestation Protocol ID
33 | content?: string; // The actual message text
34 | timestamp?: number; // Unix timestamp
35 | createdAt?: number; // Alternative timestamp field
36 | blk?: { t: number }; // Block time
37 | myBapId?: string; // Current user's BAP ID
38 | }
39 | ```
40 |
41 | ### Format 2: BMAP Transaction Format
42 | ```typescript
43 | interface Message {
44 | txid?: string;
45 | tx?: { h: string };
46 | MAP?: Array<{ // Bitcoin Metadata Attestation Protocol
47 | paymail?: string;
48 | type?: string;
49 | context?: string;
50 | channel?: string;
51 | messageID?: string;
52 | encrypted?: string;
53 | bapID?: string;
54 | }>;
55 | B?: Array<{ // Bitcoin file data
56 | encoding: string;
57 | Data: {
58 | utf8: string; // Message content here
59 | };
60 | }>;
61 | timestamp?: number;
62 | blk?: { t: number };
63 | myBapId?: string;
64 | }
65 | ```
66 |
67 | ## How the App Processes Messages
68 |
69 | 1. **Content Extraction**: The app looks for content in this order:
70 | - `msg.content` (Format 1)
71 | - `msg.B[0].Data.utf8` (Format 2)
72 | - Empty string as fallback
73 |
74 | 2. **Metadata Extraction**: The app checks for metadata in:
75 | - Direct properties (`msg.paymail`, `msg.type`, etc.)
76 | - MAP array (`msg.MAP[0].paymail`, `msg.MAP[0].type`, etc.)
77 |
78 | 3. **Timestamp**: The app uses the first available:
79 | - `msg.timestamp`
80 | - `msg.createdAt`
81 | - `msg.blk.t`
82 |
83 | 4. **Transformation**: All messages are transformed to the internal BmapTx format:
84 | ```typescript
85 | interface BmapTx {
86 | tx: { h: string };
87 | MAP: Array<{ /* metadata */ }>;
88 | B: Array<{
89 | encoding: string;
90 | Data: { utf8: string };
91 | 'content-type': string;
92 | }>;
93 | timestamp?: number;
94 | blk?: { t: number };
95 | myBapId?: string;
96 | }
97 | ```
98 |
99 | ## Real-time Messages (via EventSource)
100 |
101 | The socket middleware expects real-time messages in BMAP format with:
102 | - `data.MAP[0].type` = "message"
103 | - `data.MAP[0].context` = "channel" or "bapID" (for DMs)
104 | - `data.B[0].Data.utf8` or `data.B[0].content` for message content
105 |
106 | ## Key Points
107 |
108 | 1. The endpoint should return messages sorted by timestamp (newest first)
109 | 2. The `signers` array is optional but helps populate user information
110 | 3. Both message formats should be supported for backward compatibility
111 | 4. Content can be in either `content` field or `B[0].Data.utf8`
112 | 5. All metadata fields are optional but `type` should be "message"
--------------------------------------------------------------------------------
/src/components/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import { Component, type ErrorInfo, type ReactNode } from 'react';
2 | import styled from 'styled-components';
3 |
4 | interface Props {
5 | children?: ReactNode;
6 | }
7 |
8 | interface State {
9 | hasError: boolean;
10 | error?: Error;
11 | errorInfo?: ErrorInfo;
12 | }
13 |
14 | const ErrorContainer = styled.div`
15 | min-height: 100vh;
16 | background-color: var(--background);
17 | color: var(--foreground);
18 | padding: 32px;
19 | display: flex;
20 | align-items: center;
21 | justify-content: center;
22 | `;
23 |
24 | const ErrorContent = styled.div`
25 | max-width: 800px;
26 | width: 100%;
27 | text-align: center;
28 | `;
29 |
30 | const ErrorTitle = styled.h1`
31 | color: var(--text-danger);
32 | font-size: 2.5rem;
33 | font-weight: 700;
34 | margin-bottom: 24px;
35 | `;
36 |
37 | const ErrorDetails = styled.div`
38 | background-color: var(--card);
39 | padding: 16px;
40 | border-radius: 8px;
41 | margin-bottom: 24px;
42 | border: 1px solid var(--border);
43 | `;
44 |
45 | const ErrorText = styled.pre`
46 | white-space: pre-wrap;
47 | color: var(--muted-foreground);
48 | font-size: 14px;
49 | overflow-x: auto;
50 | text-align: left;
51 | `;
52 |
53 | const ButtonGroup = styled.div`
54 | display: flex;
55 | gap: 16px;
56 | justify-content: center;
57 | flex-wrap: wrap;
58 | `;
59 |
60 | const Button = styled.button<{ $variant?: 'primary' | 'secondary' }>`
61 | padding: 12px 24px;
62 | border-radius: 4px;
63 | font-size: 14px;
64 | font-weight: 500;
65 | border: none;
66 | cursor: pointer;
67 | transition: all 0.15s ease;
68 | min-width: 120px;
69 |
70 | ${({ $variant = 'primary' }) => {
71 | switch ($variant) {
72 | case 'primary':
73 | return `
74 | background-color: var(--primary);
75 | color: var(--primary-foreground);
76 | &:hover {
77 | background-color: var(--primary);
78 | opacity: 0.9;
79 | }
80 | `;
81 | default:
82 | return `
83 | background-color: var(--card);
84 | color: var(--foreground);
85 | border: 1px solid var(--border);
86 | &:hover {
87 | background-color: var(--accent);
88 | }
89 | `;
90 | }
91 | }}
92 |
93 | &:focus {
94 | outline: 2px solid var(--primary);
95 | outline-offset: 2px;
96 | }
97 | `;
98 |
99 | class ErrorBoundary extends Component {
100 | public state: State = {
101 | hasError: false,
102 | };
103 |
104 | public static getDerivedStateFromError(error: Error): State {
105 | return { hasError: true, error };
106 | }
107 |
108 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
109 | this.setState({ error, errorInfo });
110 | console.error('Uncaught error:', error, errorInfo);
111 | }
112 |
113 | public render() {
114 | if (this.state.hasError) {
115 | return (
116 |
117 |
118 | Sorry.. there was an error
119 |
120 | {this.state.error?.toString()}
121 |
122 |
123 |
126 |
129 |
130 |
131 |
132 | );
133 | }
134 |
135 | return this.props.children;
136 | }
137 | }
138 |
139 | export default ErrorBoundary;
140 |
--------------------------------------------------------------------------------
/src/components/dashboard/ServerList.tsx:
--------------------------------------------------------------------------------
1 | import { Plus } from 'lucide-react';
2 | import type { FC } from 'react';
3 | import { useCallback, useEffect } from 'react';
4 | import { useDispatch, useSelector } from 'react-redux';
5 | import { useNavigate } from 'react-router-dom';
6 | import { Button } from '@/components/ui/button';
7 | import { Separator } from '@/components/ui/separator';
8 | import { Tooltip } from '@/components/ui/Tooltip';
9 | import { cn } from '@/lib/utils';
10 | import { loadChannels } from '../../reducers/channelsReducer';
11 | import type { AppDispatch, RootState } from '../../store';
12 | import Avatar from './Avatar';
13 |
14 | interface Server {
15 | _id: string;
16 | name: string;
17 | description?: string;
18 | icon?: string;
19 | paymail?: string;
20 | }
21 |
22 | interface ServerState {
23 | loading: boolean;
24 | error: string | null;
25 | data: Server[];
26 | }
27 |
28 | const ServerList: FC = () => {
29 | const dispatch = useDispatch();
30 | const navigate = useNavigate();
31 |
32 | const servers = useSelector((state) => state.servers);
33 | const session = useSelector((state: RootState) => state.session);
34 |
35 | const handleServerClick = useCallback(
36 | (serverId: string) => {
37 | if (serverId === 'bitchat') {
38 | navigate('/channels');
39 | } else {
40 | navigate(`/servers/${serverId}`);
41 | }
42 | },
43 | [navigate],
44 | );
45 |
46 | const handleHomeClick = useCallback(() => {
47 | navigate('/friends');
48 | }, [navigate]);
49 |
50 | const handleAddServer = useCallback(() => {
51 | navigate('/servers/new');
52 | }, [navigate]);
53 |
54 | useEffect(() => {
55 | if (session.isAuthenticated) {
56 | void dispatch(loadChannels());
57 | }
58 | }, [session.isAuthenticated, dispatch]);
59 |
60 | return (
61 |
118 | );
119 | };
120 |
121 | export default ServerList;
122 |
--------------------------------------------------------------------------------
/src/api/channel.ts:
--------------------------------------------------------------------------------
1 | import { api } from './fetch';
2 |
3 | export interface Channel {
4 | channel: string;
5 | last_message: string;
6 | last_message_time: number;
7 | messages: number;
8 | creator: string;
9 | }
10 |
11 | export const getChannels = async (): Promise => {
12 | return api.get('/social/channels');
13 | };
14 |
15 | export const getFriends = async (idKey: string) => {
16 | const queryFriends = (idKey: string) => {
17 | return {
18 | v: 3,
19 | q: {
20 | find: {
21 | $or: [{ 'MAP.bapID': idKey }],
22 | },
23 | sort: { timestamp: -1 },
24 | limit: 100,
25 | },
26 | };
27 | };
28 |
29 | const queryFriendsB64 = (idKey: string) => btoa(JSON.stringify(queryFriends(idKey)));
30 | return api.get(`/social/q/friend/${queryFriendsB64(idKey)}?d=friends`);
31 | };
32 |
33 | export interface Message {
34 | txid?: string;
35 | tx?: { h: string };
36 | paymail?: string;
37 | type?: string;
38 | context?: string;
39 | channel?: string;
40 | messageID?: string;
41 | encrypted?: string;
42 | bapID?: string;
43 | content?: string;
44 | timestamp?: number;
45 | createdAt?: number;
46 | blk?: { t: number };
47 | myBapId?: string;
48 | MAP?: Array<{
49 | paymail?: string;
50 | type?: string;
51 | context?: string;
52 | channel?: string;
53 | messageID?: string;
54 | encrypted?: string;
55 | bapID?: string;
56 | }>;
57 | AIP?: Array<{
58 | bapId?: string;
59 | address?: string;
60 | }>;
61 | B?: Array<{
62 | encoding: string;
63 | content?: string;
64 | }>;
65 | }
66 |
67 | export interface MessageResponse {
68 | results: Message[];
69 | signers?: Array<{
70 | idKey: string;
71 | paymail: string;
72 | logo?: string;
73 | isFriend?: boolean;
74 | }>;
75 | }
76 |
77 | export interface MessageQuery {
78 | limit?: number;
79 | before?: string;
80 | after?: string;
81 | sort?: 'asc' | 'desc';
82 | }
83 |
84 | export const getMessages = async (channelName: string): Promise => {
85 | return api.get(`/social/channels/${channelName}/messages`);
86 | };
87 |
88 | export interface CreateChannelData {
89 | name?: string;
90 | description?: string;
91 | type: 'channel' | 'dm';
92 | members: string[];
93 | }
94 |
95 | export interface Reaction {
96 | id: string;
97 | emoji: string;
98 | userId: string;
99 | messageId: string;
100 | createdAt: string;
101 | }
102 |
103 | export async function getPinnedChannels(): Promise {
104 | return api.get('/social/channels/pinned');
105 | }
106 |
107 | export async function getChannel(id: string): Promise {
108 | return api.get(`/social/channels/${id}`);
109 | }
110 |
111 | export async function createChannel(data: CreateChannelData): Promise {
112 | return api.post('/social/channels', data);
113 | }
114 |
115 | export async function updateChannel(id: string, data: Partial): Promise {
116 | return api.put(`/social/channels/${id}`, data);
117 | }
118 |
119 | export async function deleteChannel(id: string): Promise {
120 | return api.delete(`/social/channels/${id}`);
121 | }
122 |
123 | export async function sendMessage(channelId: string, content: string): Promise {
124 | return api.post(`/social/channels/${channelId}/messages`, {
125 | content,
126 | });
127 | }
128 |
129 | export async function getReactions(messageId: string): Promise {
130 | return api.get(`/social/messages/${messageId}/reactions`);
131 | }
132 |
133 | export async function getDiscordReactions(messageId: string): Promise {
134 | return api.get(`/social/messages/${messageId}/discord-reactions`);
135 | }
136 |
137 | export async function getLikes(messageId: string): Promise {
138 | return api.get(`/social/messages/${messageId}/likes`);
139 | }
140 |
--------------------------------------------------------------------------------
/src/styles/typography.ts:
--------------------------------------------------------------------------------
1 | // Typography System for BitChat Nitro
2 | // This provides a consistent set of font sizes and weights across the app
3 |
4 | export const typography = {
5 | // Font sizes with corresponding line heights
6 | fontSize: {
7 | xs: '12px',
8 | sm: '14px',
9 | base: '16px',
10 | lg: '18px',
11 | xl: '20px',
12 | '2xl': '24px',
13 | '3xl': '30px',
14 | },
15 |
16 | lineHeight: {
17 | xs: '16px',
18 | sm: '20px',
19 | base: '24px',
20 | lg: '28px',
21 | xl: '28px',
22 | '2xl': '32px',
23 | '3xl': '36px',
24 | },
25 |
26 | fontWeight: {
27 | normal: 400,
28 | medium: 500,
29 | semibold: 600,
30 | bold: 700,
31 | },
32 |
33 | // Pre-defined text styles
34 | styles: {
35 | // Headings
36 | h1: {
37 | fontSize: '24px',
38 | lineHeight: '32px',
39 | fontWeight: 700,
40 | },
41 | h2: {
42 | fontSize: '20px',
43 | lineHeight: '28px',
44 | fontWeight: 600,
45 | },
46 | h3: {
47 | fontSize: '18px',
48 | lineHeight: '24px',
49 | fontWeight: 600,
50 | },
51 |
52 | // Body text
53 | body: {
54 | fontSize: '14px',
55 | lineHeight: '20px',
56 | fontWeight: 400,
57 | },
58 | bodyLarge: {
59 | fontSize: '16px',
60 | lineHeight: '24px',
61 | fontWeight: 400,
62 | },
63 | bodySmall: {
64 | fontSize: '12px',
65 | lineHeight: '16px',
66 | fontWeight: 400,
67 | },
68 |
69 | // UI elements
70 | button: {
71 | fontSize: '14px',
72 | lineHeight: '20px',
73 | fontWeight: 500,
74 | },
75 | label: {
76 | fontSize: '12px',
77 | lineHeight: '16px',
78 | fontWeight: 600,
79 | textTransform: 'uppercase' as const,
80 | },
81 | caption: {
82 | fontSize: '12px',
83 | lineHeight: '16px',
84 | fontWeight: 400,
85 | },
86 | },
87 | } as const;
88 |
89 | // Helper function to apply typography styles
90 | export const applyTypography = (style: keyof typeof typography.styles) => {
91 | return typography.styles[style];
92 | };
93 |
94 | // CSS-in-JS helper
95 | export const typographyCSS = {
96 | h1: `
97 | font-size: ${typography.styles.h1.fontSize};
98 | line-height: ${typography.styles.h1.lineHeight};
99 | font-weight: ${typography.styles.h1.fontWeight};
100 | `,
101 | h2: `
102 | font-size: ${typography.styles.h2.fontSize};
103 | line-height: ${typography.styles.h2.lineHeight};
104 | font-weight: ${typography.styles.h2.fontWeight};
105 | `,
106 | h3: `
107 | font-size: ${typography.styles.h3.fontSize};
108 | line-height: ${typography.styles.h3.lineHeight};
109 | font-weight: ${typography.styles.h3.fontWeight};
110 | `,
111 | body: `
112 | font-size: ${typography.styles.body.fontSize};
113 | line-height: ${typography.styles.body.lineHeight};
114 | font-weight: ${typography.styles.body.fontWeight};
115 | `,
116 | bodyLarge: `
117 | font-size: ${typography.styles.bodyLarge.fontSize};
118 | line-height: ${typography.styles.bodyLarge.lineHeight};
119 | font-weight: ${typography.styles.bodyLarge.fontWeight};
120 | `,
121 | bodySmall: `
122 | font-size: ${typography.styles.bodySmall.fontSize};
123 | line-height: ${typography.styles.bodySmall.lineHeight};
124 | font-weight: ${typography.styles.bodySmall.fontWeight};
125 | `,
126 | button: `
127 | font-size: ${typography.styles.button.fontSize};
128 | line-height: ${typography.styles.button.lineHeight};
129 | font-weight: ${typography.styles.button.fontWeight};
130 | `,
131 | label: `
132 | font-size: ${typography.styles.label.fontSize};
133 | line-height: ${typography.styles.label.lineHeight};
134 | font-weight: ${typography.styles.label.fontWeight};
135 | text-transform: ${typography.styles.label.textTransform};
136 | `,
137 | caption: `
138 | font-size: ${typography.styles.caption.fontSize};
139 | line-height: ${typography.styles.caption.lineHeight};
140 | font-weight: ${typography.styles.caption.fontWeight};
141 | `,
142 | };
143 |
--------------------------------------------------------------------------------
/src/components/dashboard/MemberList.tsx:
--------------------------------------------------------------------------------
1 | import { Users } from 'lucide-react';
2 | import type { FC } from 'react';
3 | import { useCallback, useEffect } from 'react';
4 | import { useDispatch, useSelector } from 'react-redux';
5 | import { useParams } from 'react-router-dom';
6 | import {
7 | Sidebar,
8 | SidebarContent,
9 | SidebarGroup,
10 | SidebarGroupContent,
11 | SidebarGroupLabel,
12 | SidebarHeader,
13 | SidebarProvider,
14 | } from '@/components/ui/sidebar';
15 | import { loadFriends, loadUsers } from '../../reducers/memberListReducer';
16 | import type { AppDispatch, RootState } from '../../store';
17 | import { UserList } from './UserList';
18 |
19 | export const MemberList: FC = () => {
20 | const dispatch = useDispatch();
21 | const params = useParams();
22 |
23 | const memberList = useSelector((state: RootState) => state.memberList);
24 | const session = useSelector((state: RootState) => state.session);
25 | const chat = useSelector((state: RootState) => state.chat);
26 | const isOpen = memberList.isOpen;
27 | const activeChannel = params.channel;
28 |
29 | // Filter users based on active channel
30 | const filteredUsers = memberList.allIds
31 | .map((id) => {
32 | const user = memberList.byId[id];
33 | if (!user?.idKey) return null;
34 |
35 | // If no active channel, show all users
36 | if (!activeChannel) return user;
37 |
38 | // Check if user has sent messages in this channel
39 | const hasMessagesInChannel = chat.messages.data.some((msg) => {
40 | const senderAddress = msg.AIP?.[0]?.address;
41 | return senderAddress && user.currentAddress === senderAddress;
42 | });
43 |
44 | return hasMessagesInChannel ? user : null;
45 | })
46 | .filter((user): user is NonNullable => user !== null)
47 | .map((user) => ({
48 | id: user.idKey,
49 | name: user.displayName || user.paymail || user.idKey,
50 | avatar: user.icon || user.logo || '',
51 | paymail: user.paymail || undefined,
52 | bapId: user.idKey,
53 | idKey: user.idKey,
54 | status: 'online' as const,
55 | }));
56 |
57 | const fetchMemberList = useCallback(() => {
58 | // Use session.isAuthenticated (includes guests) and require idKey for friends
59 | const hasIdentity = session.isAuthenticated && session.user?.idKey;
60 |
61 | if (!memberList.allIds.length && !memberList.loading) {
62 | if (activeChannel && session.isAuthenticated) {
63 | // Load channel members for authenticated users (including guests)
64 | void dispatch(loadUsers());
65 | } else if (hasIdentity) {
66 | // Only load friends if user has a BAP identity
67 | void dispatch(loadFriends());
68 | }
69 | }
70 | }, [
71 | session.isAuthenticated,
72 | session.user?.idKey,
73 | dispatch,
74 | memberList.allIds.length,
75 | memberList.loading,
76 | activeChannel,
77 | ]);
78 |
79 | useEffect(() => {
80 | fetchMemberList();
81 | }, [fetchMemberList]);
82 |
83 | if (!isOpen) {
84 | return null;
85 | }
86 |
87 | return (
88 |
92 |
93 |
94 |
95 |
96 | {activeChannel ? `#${activeChannel}` : 'Friends'}
97 |
98 |
99 |
100 |
101 |
102 | {activeChannel ? 'Members' : 'Online'} — {filteredUsers.length}
103 |
104 |
105 |
110 |
111 |
112 |
113 |
114 |
115 | );
116 | };
117 |
--------------------------------------------------------------------------------
/src/components/dashboard/modals/DirectMessageModal.tsx:
--------------------------------------------------------------------------------
1 | import type { ChangeEvent, FormEvent } from 'react';
2 | import { useState } from 'react';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import { useNavigate } from 'react-router-dom';
5 | import { Button } from '@/components/ui/button';
6 | import {
7 | Dialog,
8 | DialogContent,
9 | DialogDescription,
10 | DialogFooter,
11 | DialogHeader,
12 | DialogTitle,
13 | } from '@/components/ui/dialog';
14 | import { Input } from '@/components/ui/input';
15 | import { autofill } from '../../../api/bmap';
16 | import { api } from '../../../api/fetch';
17 | import { loadChannels } from '../../../reducers/channelsReducer';
18 | import type { AppDispatch, RootState } from '../../../store';
19 |
20 | interface Channel {
21 | id: string;
22 | name: string;
23 | members: string[];
24 | }
25 |
26 | interface DirectMessageModalProps {
27 | open: boolean;
28 | onOpenChange: (open: boolean) => void;
29 | }
30 |
31 | const DirectMessageModal = ({ open, onOpenChange }: DirectMessageModalProps) => {
32 | const [username, setUsername] = useState('');
33 | const [error, setError] = useState('');
34 | const [isLoading, setIsLoading] = useState(false);
35 | const dispatch = useDispatch();
36 | const navigate = useNavigate();
37 | const currentUser = useSelector((state: RootState) => state.session.user);
38 |
39 | const handleSubmit = async (e: FormEvent) => {
40 | e.preventDefault();
41 | setError('');
42 | setIsLoading(true);
43 |
44 | if (!currentUser?.idKey) {
45 | setError('Not logged in');
46 | setIsLoading(false);
47 | return;
48 | }
49 |
50 | try {
51 | // Find user by username using autofill
52 | const users = await autofill(username);
53 |
54 | const targetUser = users.find(
55 | (user) =>
56 | user.name.toLowerCase() === username.toLowerCase() ||
57 | user.paymail?.toLowerCase() === username.toLowerCase(),
58 | );
59 | if (!targetUser) {
60 | setError('User not found');
61 | setIsLoading(false);
62 | return;
63 | }
64 |
65 | // Create DM channel
66 | const channel = await api.post('/channels', {
67 | type: 'dm',
68 | members: [currentUser.idKey, targetUser.idKey],
69 | });
70 |
71 | await dispatch(loadChannels());
72 | navigate(`/channels/${channel.id}`);
73 | onOpenChange(false);
74 | } catch (err) {
75 | setError(err instanceof Error ? err.message : 'Failed to create DM');
76 | } finally {
77 | setIsLoading(false);
78 | }
79 | };
80 |
81 | const handleInputChange = (e: ChangeEvent) => {
82 | setUsername(e.target.value);
83 | };
84 |
85 | return (
86 |
129 | );
130 | };
131 |
132 | export default DirectMessageModal;
133 |
--------------------------------------------------------------------------------
/API_STRUCTURE.md:
--------------------------------------------------------------------------------
1 | # BMAP API Expected Response Structures
2 |
3 | Based on the frontend code expectations and curl testing:
4 |
5 | ## /channels endpoint
6 | Current response (minimal):
7 | ```json
8 | [
9 | { "channel": "nitro" },
10 | { "channel": "test" }
11 | ]
12 | ```
13 |
14 | Expected response (based on Channel interface):
15 | ```json
16 | [
17 | {
18 | "channel": "nitro",
19 | "last_message": "Latest message content",
20 | "last_message_time": 1748143158,
21 | "messages": 1234,
22 | "creator": "satchmo@handcash.io"
23 | }
24 | ]
25 | ```
26 |
27 | ## /channels/:channelId/messages endpoint
28 | Current response structure:
29 | ```json
30 | {
31 | "channel": "test",
32 | "page": 1,
33 | "limit": 100,
34 | "count": 1506,
35 | "results": [...messages array...],
36 | "signers": []
37 | }
38 | ```
39 |
40 | Expected by frontend (MessageResponse interface):
41 | ```json
42 | {
43 | "results": [...messages array...],
44 | "signers": [
45 | {
46 | "idKey": "string",
47 | "paymail": "string",
48 | "logo": "string (optional)",
49 | "isFriend": "boolean (optional)"
50 | }
51 | ]
52 | }
53 | ```
54 |
55 | ## /identities endpoint
56 | Current response has validation errors expecting:
57 | - Each identity object should have `_id` field (currently has `idKey`)
58 | - The `identity` field is a JSON string that needs to be parsed
59 |
60 | Expected structure:
61 | ```json
62 | [
63 | {
64 | "_id": "string (currently missing, using idKey)",
65 | "idKey": "string",
66 | "addresses": [...],
67 | "identity": {
68 | "alternateName": "string",
69 | "description": "string",
70 | "image": "string",
71 | "paymail": "string"
72 | },
73 | "block": 123456,
74 | "timestamp": 1234567890
75 | }
76 | ]
77 | ```
78 |
79 | ## Message Structure (BmapTx)
80 | Messages in results arrays should have:
81 | ```json
82 | {
83 | "tx": { "h": "transaction hash" },
84 | "_id": "optional string",
85 | "MAP": [{
86 | "cmd": "SET (optional)",
87 | "app": "bitchatnitro.com",
88 | "type": "message",
89 | "paymail": "sender@example.com",
90 | "context": "channel",
91 | "channel": "test",
92 | "messageID": "optional",
93 | "encrypted": "optional",
94 | "bapID": "optional",
95 | "emoji": "optional for reactions"
96 | }],
97 | "B": [{
98 | "content": "message content (optional)",
99 | "content-type": "text/plain (optional)",
100 | "encoding": "utf-8",
101 | "content": "message content"
102 | }],
103 | "timestamp": 1234567890,
104 | "blk": {
105 | "i": 123456,
106 | "t": 1234567890,
107 | "h": "block hash (optional)"
108 | }
109 | }
110 | ```
111 |
112 | ## Event Stream Message Format
113 | When messages come through the event stream:
114 | ```json
115 | {
116 | "type": "message",
117 | "data": [{
118 | "_id": "message id",
119 | "tx": { "h": "transaction hash" },
120 | "B": [{
121 | "content": "hello?",
122 | "content-type": "text/plain",
123 | "encoding": "utf-8"
124 | }],
125 | "MAP": [{
126 | "cmd": "SET",
127 | "app": "bitchatnitro.com",
128 | "type": "message",
129 | "paymail": "Anonymous@yours.org",
130 | "context": "channel",
131 | "channel": "test"
132 | }],
133 | "blk": { "i": 0, "t": 0, "h": "" },
134 | "timestamp": 1748143158
135 | }]
136 | }
137 | ```
138 |
139 | ## Validation Issues Found
140 |
141 | 1. **Encoding field**: Sometimes missing, expected to be "utf-8" string
142 | 2. **Identity field**: API returns object but validation expects different structure
143 | 3. **Channel metadata**: /channels endpoint missing required fields
144 | 4. **Message wrapper**: Some endpoints wrap results differently than expected
145 |
146 | ## Recommendations
147 |
148 | 1. Standardize response wrapper: `{ results: [...], signers: [...] }`
149 | 2. Ensure all encoding fields are present as "utf-8"
150 | 3. Add missing channel metadata fields
151 | 4. Consider making more fields optional in validation schema
152 | 5. Document exact field requirements in OpenAPI/Swagger spec
--------------------------------------------------------------------------------
/src/components/ui/EmojiPicker.tsx:
--------------------------------------------------------------------------------
1 | import { EmojiPicker as FrimoussePicker } from 'frimousse';
2 | import type { FC } from 'react';
3 | import styled from 'styled-components';
4 |
5 | const StyledEmojiPicker = styled.div`
6 | .frimousse-root {
7 | display: flex;
8 | height: 368px;
9 | width: fit-content;
10 | flex-direction: column;
11 | background-color: var(--card);
12 | border-radius: 8px;
13 | border: 1px solid var(--border);
14 | }
15 |
16 | .frimousse-search {
17 | z-index: 10;
18 | margin: 8px;
19 | margin-bottom: 4px;
20 | appearance: none;
21 | border-radius: 6px;
22 | background-color: var(--input);
23 | padding: 8px 10px;
24 | font-size: 14px;
25 | border: none;
26 | color: var(--foreground);
27 |
28 | &:focus {
29 | outline: 2px solid var(--primary);
30 | outline-offset: -2px;
31 | }
32 | }
33 |
34 | .frimousse-viewport {
35 | position: relative;
36 | flex: 1;
37 | outline: none;
38 | overflow: hidden;
39 | }
40 |
41 | .frimousse-loading,
42 | .frimousse-empty {
43 | position: absolute;
44 | inset: 0;
45 | display: flex;
46 | align-items: center;
47 | justify-content: center;
48 | color: var(--muted-foreground);
49 | font-size: 14px;
50 | }
51 |
52 | .frimousse-list {
53 | user-select: none;
54 | padding-bottom: 6px;
55 | overflow-y: auto;
56 | height: 100%;
57 | }
58 |
59 | .frimousse-category-header {
60 | background-color: var(--card);
61 | padding: 12px 12px 6px;
62 | font-weight: 600;
63 | color: var(--muted-foreground);
64 | font-size: 12px;
65 | text-transform: uppercase;
66 | letter-spacing: 0.02em;
67 | }
68 |
69 | .frimousse-row {
70 | scroll-margin: 6px;
71 | padding: 0 6px;
72 | }
73 |
74 | .frimousse-emoji {
75 | display: flex;
76 | width: 32px;
77 | height: 32px;
78 | align-items: center;
79 | justify-content: center;
80 | border-radius: 6px;
81 | font-size: 18px;
82 | border: none;
83 | background: none;
84 | cursor: pointer;
85 | transition: background-color 0.1s;
86 |
87 | &:hover,
88 | &[data-active] {
89 | background-color: var(--accent);
90 | }
91 | }
92 | `;
93 |
94 | interface EmojiPickerProps {
95 | onEmojiSelect: (emoji: string) => void;
96 | }
97 |
98 | export const EmojiPicker: FC = ({ onEmojiSelect }) => {
99 | return (
100 |
101 |
102 |
106 |
107 |
108 | Loading emojis...
109 |
110 |
111 | No emoji found.
112 |
113 | (
117 |
118 | {category.label}
119 |
120 | ),
121 | Row: ({ children, ...props }) => (
122 |
123 | {children}
124 |
125 | ),
126 | Emoji: ({ emoji, ...props }) => (
127 |
136 | ),
137 | }}
138 | />
139 |
140 |
141 |
142 | );
143 | };
144 |
--------------------------------------------------------------------------------
/src/components/dashboard/Messages.tsx:
--------------------------------------------------------------------------------
1 | import { last } from 'lodash';
2 | import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import { useParams } from 'react-router-dom';
5 | import { fetchMessages, fetchMoreMessages } from '../../reducers/chatReducer';
6 | import type { AppDispatch, RootState } from '../../store';
7 | import MessageCard from './MessageCard';
8 |
9 | const Messages: FC = () => {
10 | const params = useParams();
11 | const dispatch = useDispatch();
12 | const messageListRef = useRef(null);
13 | const [shouldScrollToBottom, setShouldScrollToBottom] = useState(true);
14 | const [isLoadingMore, setIsLoadingMore] = useState(false);
15 |
16 | const messages = useSelector((state: RootState) => state.chat.messages);
17 | const reactions = useSelector((state: RootState) => state.chat.reactions);
18 | const currentUser = useSelector((state: RootState) => state.session.user);
19 |
20 | const activeUserId = useMemo(() => params.user, [params.user]);
21 |
22 | const channelName =
23 | params.channel || activeUserId || last(window?.location?.pathname?.split('/'));
24 |
25 | const fetchMessageList = useCallback(() => {
26 | if (params.channel) {
27 | dispatch(fetchMessages({ channelName: params.channel }));
28 | } else if (params.user && currentUser?.bapId) {
29 | dispatch(
30 | fetchMessages({
31 | userId: params.user,
32 | currentUserId: currentUser.bapId,
33 | }),
34 | );
35 | }
36 | }, [dispatch, params.channel, params.user, currentUser?.bapId]);
37 |
38 | useEffect(() => {
39 | fetchMessageList();
40 | }, [fetchMessageList]);
41 |
42 | // Scroll to bottom (which is actually the top due to flex-direction: column-reverse)
43 | useEffect(() => {
44 | if (shouldScrollToBottom && messageListRef.current) {
45 | messageListRef.current.scrollTop = 0;
46 | }
47 | }, [shouldScrollToBottom]);
48 |
49 | const handleScroll = useCallback(
50 | (event: {
51 | currentTarget: {
52 | scrollTop: number;
53 | scrollHeight: number;
54 | clientHeight: number;
55 | };
56 | }) => {
57 | const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
58 |
59 | // Check if we're at the bottom (which is actually scrollTop = 0 due to column-reverse)
60 | setShouldScrollToBottom(scrollTop === 0);
61 |
62 | // Check if we're at the top (which is actually the bottom due to column-reverse)
63 | if (
64 | Math.abs(scrollHeight - clientHeight - scrollTop) < 1 &&
65 | !isLoadingMore &&
66 | messages.hasMore &&
67 | channelName
68 | ) {
69 | setIsLoadingMore(true);
70 | try {
71 | if (params.channel) {
72 | dispatch(fetchMoreMessages({ channelName: params.channel }));
73 | } else if (params.user && currentUser?.bapId) {
74 | dispatch(
75 | fetchMoreMessages({
76 | userId: params.user,
77 | currentUserId: currentUser.bapId,
78 | }),
79 | );
80 | }
81 | } finally {
82 | setIsLoadingMore(false);
83 | }
84 | }
85 | },
86 | [
87 | dispatch,
88 | isLoadingMore,
89 | messages.hasMore,
90 | channelName,
91 | params.channel,
92 | params.user,
93 | currentUser?.bapId,
94 | ],
95 | );
96 |
97 | if (messages.loading) {
98 | return (
99 |
100 |
101 | Loading messages...
102 |
103 |
104 | );
105 | }
106 |
107 | return (
108 |
109 |
114 | {messages.data.map((message, index) => (
115 |
120 | ))}
121 |
122 |
123 | );
124 | };
125 |
126 | export default Messages;
127 |
--------------------------------------------------------------------------------
/src/utils/backupDetector.ts:
--------------------------------------------------------------------------------
1 | import type { BapMasterBackup, BapMemberBackup, OneSatBackup, WifBackup } from 'bitcoin-backup';
2 |
3 | export enum BackupType {
4 | BapMaster = 'BapMaster',
5 | BapMember = 'BapMember',
6 | Wif = 'Wif',
7 | OneSat = 'OneSat',
8 | LegacyPlaintext = 'LegacyPlaintext',
9 | Unknown = 'Unknown',
10 | }
11 |
12 | export interface BackupDetectionResult {
13 | type: BackupType;
14 | isEncrypted: boolean;
15 | data:
16 | | BapMasterBackup
17 | | BapMemberBackup
18 | | OneSatBackup
19 | | WifBackup
20 | | { xprv: string; ids: { idKey: string }[] }
21 | | Record
22 | | string;
23 | }
24 |
25 | /**
26 | * Detects the type of backup file from its content
27 | */
28 | export function detectBackupType(content: string): BackupDetectionResult {
29 | try {
30 | // Try to parse as JSON first
31 | const parsed = JSON.parse(content);
32 |
33 | // Check for encrypted backup format (bitcoin-backup package)
34 | if (parsed.data && parsed.salt && parsed.iv) {
35 | // Encrypted backup - need to check the schema field after decryption
36 | return {
37 | type: BackupType.Unknown, // Will be determined after decryption
38 | isEncrypted: true,
39 | data: parsed,
40 | };
41 | }
42 |
43 | // Check for BapMasterBackup (decrypted)
44 | if (parsed.hdKey && parsed.identities && Array.isArray(parsed.identities)) {
45 | return {
46 | type: BackupType.BapMaster,
47 | isEncrypted: false,
48 | data: parsed as BapMasterBackup,
49 | };
50 | }
51 |
52 | // Check for BapMemberBackup (decrypted)
53 | if (
54 | parsed.identity &&
55 | typeof parsed.identity === 'object' &&
56 | parsed.identity.name &&
57 | parsed.identity.rootAddress
58 | ) {
59 | return {
60 | type: BackupType.BapMember,
61 | isEncrypted: false,
62 | data: parsed as BapMemberBackup,
63 | };
64 | }
65 |
66 | // Check for WifBackup (decrypted)
67 | if (parsed.wif && typeof parsed.wif === 'string') {
68 | return {
69 | type: BackupType.Wif,
70 | isEncrypted: false,
71 | data: parsed as WifBackup,
72 | };
73 | }
74 |
75 | // Check for OneSatBackup (decrypted)
76 | if (parsed.ordinals && parsed.paymail) {
77 | return {
78 | type: BackupType.OneSat,
79 | isEncrypted: false,
80 | data: parsed as OneSatBackup,
81 | };
82 | }
83 |
84 | // Check for legacy plaintext format (current implementation)
85 | if (parsed.xprv && parsed.ids && Array.isArray(parsed.ids)) {
86 | return {
87 | type: BackupType.LegacyPlaintext,
88 | isEncrypted: false,
89 | data: parsed,
90 | };
91 | }
92 |
93 | return {
94 | type: BackupType.Unknown,
95 | isEncrypted: false,
96 | data: parsed,
97 | };
98 | } catch {
99 | // Not valid JSON, might be a raw WIF or other format
100 | return {
101 | type: BackupType.Unknown,
102 | isEncrypted: false,
103 | data: content,
104 | };
105 | }
106 | }
107 |
108 | /**
109 | * Converts different backup formats to the legacy format expected by the app
110 | */
111 | export function convertToLegacyFormat(
112 | backupResult: BackupDetectionResult,
113 | ): { xprv: string; ids: { idKey: string }[] } | null {
114 | switch (backupResult.type) {
115 | case BackupType.BapMaster: {
116 | const master = backupResult.data as BapMasterBackup;
117 | // Convert identities to the expected format
118 | const ids = master.identities.map((identity) => ({
119 | idKey: identity.name || identity.rootAddress,
120 | }));
121 | return {
122 | xprv: master.hdKey.xprv,
123 | ids,
124 | };
125 | }
126 |
127 | case BackupType.BapMember: {
128 | const member = backupResult.data as BapMemberBackup;
129 | return {
130 | xprv: member.identity.xprv,
131 | ids: [
132 | {
133 | idKey: member.identity.name || member.identity.rootAddress,
134 | },
135 | ],
136 | };
137 | }
138 |
139 | case BackupType.Wif: {
140 | // WIF backup doesn't have HD key structure
141 | console.warn('WIF backup format is not fully supported for BAP identity');
142 | return null;
143 | }
144 |
145 | case BackupType.LegacyPlaintext:
146 | return backupResult.data;
147 |
148 | default:
149 | return null;
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/scripts/validate-build.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bun
2 |
3 | import { exec } from 'child_process';
4 | import { promisify } from 'util';
5 | import chalk from 'chalk';
6 |
7 | const execAsync = promisify(exec);
8 |
9 | interface ValidationResult {
10 | step: string;
11 | success: boolean;
12 | duration: number;
13 | output?: string;
14 | error?: string;
15 | }
16 |
17 | class BuildValidator {
18 | private results: ValidationResult[] = [];
19 | private startTime = Date.now();
20 |
21 | async runStep(
22 | step: string,
23 | command: string,
24 | options?: { timeout?: number },
25 | ): Promise {
26 | const stepStart = Date.now();
27 |
28 | try {
29 | const { stdout } = await execAsync(command, {
30 | timeout: options?.timeout || 60000,
31 | cwd: process.cwd(),
32 | });
33 |
34 | const duration = Date.now() - stepStart;
35 | const result: ValidationResult = {
36 | step,
37 | success: true,
38 | duration,
39 | output: stdout.trim(),
40 | };
41 | this.results.push(result);
42 | return result;
43 | } catch (error: unknown) {
44 | const duration = Date.now() - stepStart;
45 | const errorMessage =
46 | error instanceof Error ? error.message : String(error);
47 |
48 | const result: ValidationResult = {
49 | step,
50 | success: false,
51 | duration,
52 | error: errorMessage,
53 | };
54 |
55 | this.results.push(result);
56 | return result;
57 | }
58 | }
59 |
60 | async validateBuild() {
61 | // 1. Lint check
62 | await this.runStep('TypeScript & Linting', 'bun lint');
63 |
64 | // 2. Build check
65 | await this.runStep('Production Build', 'bun run build');
66 |
67 | // 3. Type checking
68 | await this.runStep('Type Checking', 'bun run type-check || tsc --noEmit');
69 |
70 | // 4. Unit tests (if they exist)
71 | await this.runStep('Unit Tests', 'bun test || echo "No unit tests found"');
72 |
73 | // 5. E2E tests
74 | await this.runStep('E2E Tests', 'bun run test --reporter=dot', {
75 | timeout: 120000,
76 | });
77 |
78 | // 6. Visual regression tests
79 | await this.runStep('Visual Tests', 'bun run test:visual --reporter=dot', {
80 | timeout: 180000,
81 | });
82 |
83 | // 7. Bundle analysis (optional)
84 | await this.runStep(
85 | 'Bundle Analysis',
86 | 'bun run build --analyze || echo "Bundle analysis not configured"',
87 | );
88 |
89 | this.printSummary();
90 | }
91 |
92 | printSummary() {
93 | const _totalDuration = Date.now() - this.startTime;
94 | const _successCount = this.results.filter((r) => r.success).length;
95 | const failureCount = this.results.filter((r) => !r.success).length;
96 |
97 | for (const result of this.results) {
98 | const _icon = result.success ? '✅' : '❌';
99 | const _color = result.success ? chalk.green : chalk.red;
100 | const _duration = `${result.duration}ms`;
101 | }
102 |
103 | if (failureCount > 0) {
104 | process.exit(1);
105 | } else {
106 | process.exit(0);
107 | }
108 | }
109 |
110 | async validateQuick() {
111 | await this.runStep('Linting', 'bun lint');
112 | await this.runStep('Build', 'bun run build');
113 |
114 | this.printSummary();
115 | }
116 |
117 | async validateVisual() {
118 | const devProcess = exec('bun run dev');
119 |
120 | // Wait for server to start
121 | await new Promise((resolve) => setTimeout(resolve, 5000));
122 |
123 | await this.runStep('Visual Regression Tests', 'bun run test:visual', {
124 | timeout: 300000,
125 | });
126 |
127 | devProcess.kill();
128 | this.printSummary();
129 | }
130 | }
131 |
132 | async function main() {
133 | const validator = new BuildValidator();
134 | const command = process.argv[2];
135 |
136 | switch (command) {
137 | case 'quick':
138 | await validator.validateQuick();
139 | break;
140 | case 'visual':
141 | await validator.validateVisual();
142 | break;
143 | default:
144 | await validator.validateBuild();
145 | break;
146 | }
147 | }
148 |
149 | if (require.main === module) {
150 | main().catch((error) => {
151 | console.error(chalk.red('💥 Validation failed:'), error);
152 | process.exit(1);
153 | });
154 | }
155 |
156 | export { BuildValidator };
157 |
--------------------------------------------------------------------------------
/src/components/dashboard/Avatar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { API_BASE_URL } from '../../config/constants';
4 |
5 | interface AvatarProps {
6 | size?: string;
7 | paymail?: string;
8 | icon?: string;
9 | status?: 'online' | 'offline' | 'away' | 'dnd';
10 | showStatus?: boolean;
11 | className?: string;
12 | }
13 |
14 | const AvatarWrapper = styled.div`
15 | position: relative;
16 | display: inline-block;
17 | `;
18 |
19 | const AvatarContainer = styled.div<{ size: string }>`
20 | width: ${({ size }) => size};
21 | height: ${({ size }) => size};
22 | border-radius: 50%;
23 | overflow: hidden;
24 | flex-shrink: 0;
25 | background: var(--background-glass);
26 | backdrop-filter: blur(var(--blur-light));
27 | -webkit-backdrop-filter: blur(var(--blur-light));
28 | border: 2px solid var(--border-subtle);
29 | display: flex;
30 | align-items: center;
31 | justify-content: center;
32 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
33 | cursor: pointer;
34 |
35 | &:hover {
36 | border-color: var(--border-glass);
37 | box-shadow: var(--elevation-medium);
38 | transform: scale(1.05);
39 | }
40 | `;
41 |
42 | const AvatarImage = styled.img`
43 | width: 100%;
44 | height: 100%;
45 | object-fit: cover;
46 | `;
47 |
48 | const AvatarFallback = styled.div<{ size: string }>`
49 | width: ${({ size }) => size};
50 | height: ${({ size }) => size};
51 | background: linear-gradient(135deg, var(--brand-experiment), var(--brand-experiment-darker));
52 | color: var(--white);
53 | display: flex;
54 | align-items: center;
55 | justify-content: center;
56 | font-weight: 600;
57 | font-size: ${({ size }) => `${Number.parseInt(size, 10) / 2.5}px`};
58 | text-transform: uppercase;
59 | letter-spacing: 0.5px;
60 | `;
61 |
62 | const StatusIndicator = styled.div<{
63 | status: 'online' | 'offline' | 'away' | 'dnd';
64 | size: string;
65 | }>`
66 | position: absolute;
67 | bottom: -2px;
68 | right: -2px;
69 | width: ${({ size }) => `${Math.max(Number.parseInt(size, 10) * 0.25, 12)}px`};
70 | height: ${({ size }) => `${Math.max(Number.parseInt(size, 10) * 0.25, 12)}px`};
71 | border-radius: 50%;
72 | border: 2px solid var(--background);
73 | background-color: ${({ status }) => {
74 | switch (status) {
75 | case 'online':
76 | return 'var(--chart-2)';
77 | case 'away':
78 | return 'var(--status-warning)';
79 | case 'dnd':
80 | return 'var(--destructive)';
81 | default:
82 | return 'var(--muted-foreground)';
83 | }
84 | }};
85 | box-shadow: 0 0 8px ${({ status }) => {
86 | switch (status) {
87 | case 'online':
88 | return 'var(--status-positive-glow)';
89 | case 'away':
90 | return 'var(--status-warning-glow)';
91 | case 'dnd':
92 | return 'var(--status-danger-glow)';
93 | default:
94 | return 'transparent';
95 | }
96 | }};
97 | transition: all 0.2s ease;
98 |
99 | ${({ status }) =>
100 | status === 'online' &&
101 | `
102 | animation: pulse 2s infinite;
103 | `}
104 | `;
105 |
106 | const Avatar: React.FC = ({
107 | size = '40px',
108 | paymail = '',
109 | icon = '',
110 | status = 'offline',
111 | showStatus = false,
112 | className = '',
113 | }): React.ReactElement => {
114 | const [imgError, setImgError] = React.useState(false);
115 |
116 | const getInitials = (paymail: string) => {
117 | const parts = paymail.split('@')[0].split('.');
118 | if (parts.length >= 2) {
119 | return `${parts[0][0]}${parts[1][0]}`;
120 | }
121 | return parts[0].slice(0, 2);
122 | };
123 |
124 | const handleError = () => {
125 | setImgError(true);
126 | };
127 |
128 | const avatarContent =
129 | !icon || imgError ? (
130 |
131 | {paymail ? getInitials(paymail) : '??'}
132 |
133 | ) : (
134 |
135 |
140 |
141 | );
142 |
143 | if (showStatus) {
144 | return (
145 |
146 | {avatarContent}
147 |
148 |
149 | );
150 | }
151 |
152 | return avatarContent;
153 | };
154 |
155 | export default Avatar;
156 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { XIcon } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | function Dialog({
8 | ...props
9 | }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function DialogTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return
17 | }
18 |
19 | function DialogPortal({
20 | ...props
21 | }: React.ComponentProps) {
22 | return
23 | }
24 |
25 | function DialogClose({
26 | ...props
27 | }: React.ComponentProps) {
28 | return
29 | }
30 |
31 | function DialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function DialogContent({
48 | className,
49 | children,
50 | showCloseButton = true,
51 | ...props
52 | }: React.ComponentProps & {
53 | showCloseButton?: boolean
54 | }) {
55 | return (
56 |
57 |
58 |
66 | {children}
67 | {showCloseButton && (
68 |
72 |
73 | Close
74 |
75 | )}
76 |
77 |
78 | )
79 | }
80 |
81 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
82 | return (
83 |
88 | )
89 | }
90 |
91 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
92 | return (
93 |
101 | )
102 | }
103 |
104 | function DialogTitle({
105 | className,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
114 | )
115 | }
116 |
117 | function DialogDescription({
118 | className,
119 | ...props
120 | }: React.ComponentProps) {
121 | return (
122 |
127 | )
128 | }
129 |
130 | export {
131 | Dialog,
132 | DialogClose,
133 | DialogContent,
134 | DialogDescription,
135 | DialogFooter,
136 | DialogHeader,
137 | DialogOverlay,
138 | DialogPortal,
139 | DialogTitle,
140 | DialogTrigger,
141 | }
142 |
--------------------------------------------------------------------------------
/src/api/fetch.ts:
--------------------------------------------------------------------------------
1 | import { API_BASE_URL } from '../config/constants';
2 | import { requestSigmaSignature } from '../lib/sigma-iframe-signer';
3 |
4 | type RequestInit = globalThis.RequestInit;
5 | type Event = globalThis.Event;
6 |
7 | interface FetchOptions extends RequestInit {
8 | params?: Record;
9 | requiresAuth?: boolean;
10 | }
11 |
12 | interface SSEOptions {
13 | onMessage?: (data: T) => void;
14 | onError?: (error: Event) => void;
15 | onOpen?: () => void;
16 | }
17 |
18 | interface RequestOptions {
19 | requiresAuth?: boolean;
20 | params?: Record;
21 | }
22 |
23 | export async function apiFetch(path: string, options: FetchOptions = {}): Promise {
24 | const { params, requiresAuth = false, ...fetchOptions } = options;
25 |
26 | let url = `${API_BASE_URL}${path}`;
27 | if (params) {
28 | const searchParams = new URLSearchParams(params);
29 | url += `?${searchParams.toString()}`;
30 | }
31 |
32 | const headers: Record = {
33 | 'Content-Type': 'application/json',
34 | Accept: 'application/json',
35 | ...(options.headers as Record),
36 | };
37 |
38 | // Add Sigma authentication if required
39 | if (requiresAuth) {
40 | try {
41 | const requestBody = options.body ? String(options.body) : undefined;
42 | const authToken = await requestSigmaSignature(path, requestBody, 'brc77');
43 | headers['X-Auth-Token'] = authToken;
44 | } catch (error) {
45 | console.error('[Bitchat] Sigma signing failed:', error);
46 | throw new Error('Failed to sign request with Sigma');
47 | }
48 | }
49 |
50 | try {
51 | const response = await fetch(url, {
52 | headers,
53 | mode: 'cors',
54 | credentials: 'include',
55 | ...fetchOptions,
56 | });
57 |
58 | if (!response.ok) {
59 | const errorText = await response.text();
60 | throw new Error(`Fetch error: ${errorText}`);
61 | }
62 |
63 | return response.json();
64 | } catch (error) {
65 | console.error('API Request Failed:', {
66 | url,
67 | method: options.method || 'GET',
68 | error: error instanceof Error ? error.message : 'Unknown error',
69 | });
70 | throw error;
71 | }
72 | }
73 |
74 | export function connectSSE(path: string, options: SSEOptions = {}) {
75 | const url = `${API_BASE_URL}${path}`;
76 | const eventSource = new EventSource(url, { withCredentials: true });
77 |
78 | eventSource.onmessage = (event) => {
79 | try {
80 | const data = JSON.parse(event.data) as T;
81 | options.onMessage?.(data);
82 | } catch (error) {
83 | console.error('Error parsing SSE message:', error);
84 | }
85 | };
86 |
87 | eventSource.onerror = (error) => {
88 | console.error('SSE Error:', error);
89 | options.onError?.(error);
90 | };
91 |
92 | eventSource.onopen = () => {
93 | options.onOpen?.();
94 | };
95 |
96 | return {
97 | close: () => {
98 | eventSource.close();
99 | },
100 | };
101 | }
102 |
103 | export const api = {
104 | async get(path: string, options: RequestOptions = {}): Promise {
105 | const { requiresAuth = false, params } = options;
106 | const headers: Record = {
107 | 'Content-Type': 'application/json',
108 | Accept: 'application/json',
109 | };
110 |
111 | // Add Sigma authentication if required
112 | if (requiresAuth) {
113 | try {
114 | const authToken = await requestSigmaSignature(path, undefined, 'brc77');
115 | headers['X-Auth-Token'] = authToken;
116 | } catch (error) {
117 | console.error('[Bitchat] Sigma signing failed:', error);
118 | throw new Error('Failed to sign request with Sigma');
119 | }
120 | }
121 |
122 | const queryString = params ? `?${new URLSearchParams(params)}` : '';
123 | const url = `${API_BASE_URL}${path}${queryString}`;
124 |
125 | const response = await fetch(url, { headers });
126 | if (!response.ok) {
127 | throw new Error(`HTTP error! status: ${response.status}`);
128 | }
129 | const data = await response.json();
130 | return data as T;
131 | },
132 |
133 | post: (path: string, data?: unknown, options?: FetchOptions) =>
134 | apiFetch(path, {
135 | ...options,
136 | method: 'POST',
137 | body: data ? JSON.stringify(data) : undefined,
138 | }),
139 |
140 | put: (path: string, data?: unknown, options?: FetchOptions) =>
141 | apiFetch(path, {
142 | ...options,
143 | method: 'PUT',
144 | body: data ? JSON.stringify(data) : undefined,
145 | }),
146 |
147 | delete: (path: string, options?: FetchOptions) =>
148 | apiFetch(path, {
149 | ...options,
150 | method: 'DELETE',
151 | }),
152 |
153 | sse: connectSSE,
154 | };
155 |
--------------------------------------------------------------------------------