Files
dumpsterChat/web/src/components/ReactionBar.tsx
T
hobokenchicken bb650ac2a0 Phase 3: Polish & PWA
Backend:
- DB: reactions table, invites table, reply_to column on messages
- gateway/events.go: added REACTION_ADD, REACTION_REMOVE events
- internal/reaction/handlers.go: reaction CRUD with WebSocket broadcast
- internal/invite/handlers.go: invite creation, info, join with code
- gateway/hub.go: presence tracking with idle detection
- gateway/client.go: idle timeout support

Frontend - Social:
- TypingIndicator: real-time 'user is typing...' display
- ReactionBar: emoji reactions on messages with counts
- EmojiPicker: searchable emoji grid for reactions
- ReplyBar: quoted reply display above messages
- MentionPopup: @mention autocomplete with user list

Frontend - PWA:
- manifest.json: PWA manifest with theme color and icons
- sw.js: service worker with cache-first strategy and push support
- stores/push.ts: push notification subscription management
- InstallPrompt: 'Add to Home Screen' banner

Frontend - Mobile:
- MobileNav: bottom nav bar for mobile (servers/channels/chat/members)
- MobileDrawer: slide-out drawer with server bar + channel list
- index.html: PWA meta tags, safe area viewport

Frontend - Polish:
- ThemeToggle: dark/light mode switch with localStorage persistence
- InviteModal: generate invite links with expiry and max uses
- JoinServer: /invite/:code join flow
- stores/typing.ts: typing indicator state management
- stores/presence.ts: real-time presence tracking
- tailwind.config.js: darkMode: 'class', light mode color tokens
- styles/index.css: light mode CSS variable overrides
2026-06-28 16:44:39 -04:00

63 lines
1.6 KiB
TypeScript

import { useState } from 'react';
import { api } from '../lib/api.ts';
interface Reaction {
emoji: string;
count: number;
users: string[];
reacted: boolean;
}
interface ReactionBarProps {
messageId: string;
reactions: Reaction[];
onRefresh: () => void;
}
export function ReactionBar({ messageId, reactions, onRefresh }: ReactionBarProps) {
const [loading, setLoading] = useState(false);
const toggleReaction = async (emoji: string) => {
if (loading) return;
setLoading(true);
try {
const existing = reactions.find(r => r.emoji === emoji);
if (existing?.reacted) {
await api.delete(`/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`);
} else {
await api.post(`/messages/${messageId}/reactions`, { emoji });
}
onRefresh();
} catch (error) {
console.error('Failed to toggle reaction:', error);
} finally {
setLoading(false);
}
};
if (reactions.length === 0) return null;
return (
<div className="flex flex-wrap gap-1 mt-1">
{reactions.map((reaction) => (
<button
key={reaction.emoji}
onClick={() => toggleReaction(reaction.emoji)}
disabled={loading}
className={`
px-1.5 py-0.5 text-xs font-mono border rounded-sm
transition-colors duration-100
${reaction.reacted
? 'border-gb-orange bg-gb-bg-t text-gb-orange'
: 'border-gb-bg-t bg-gb-bg-s text-gb-fg-f hover:border-gb-fg-f'
}
`}
title={reaction.users.join(', ')}
>
{reaction.emoji} {reaction.count}
</button>
))}
</div>
);
}