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
This commit is contained in:
2026-06-28 16:44:39 -04:00
parent ab35bdd1ae
commit bb650ac2a0
29 changed files with 1811 additions and 30 deletions
+77
View File
@@ -0,0 +1,77 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
interface PushState {
isSupported: boolean;
isSubscribed: boolean;
permission: NotificationPermission;
subscribe: () => Promise<void>;
unsubscribe: () => Promise<void>;
requestPermission: () => Promise<NotificationPermission>;
}
function urlBase64ToUint8Array(base64String: string): BufferSource {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export const usePushStore = create<PushState>((set, get) => ({
isSupported: 'serviceWorker' in navigator && 'PushManager' in window,
isSubscribed: false,
permission: 'default',
requestPermission: async () => {
if (!get().isSupported) return 'denied';
const permission = await Notification.requestPermission();
set({ permission });
return permission;
},
subscribe: async () => {
if (!get().isSupported) return;
try {
// Register service worker
const registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
// Get VAPID public key from server
const { publicKey } = await api.get<{ publicKey: string }>('/push/vapid-key');
if (!publicKey) return;
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
// Send subscription to server
await api.post('/push/subscribe', subscription.toJSON());
set({ isSubscribed: true });
} catch (error) {
console.error('Failed to subscribe to push:', error);
}
},
unsubscribe: async () => {
if (!get().isSupported) return;
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await api.post('/push/unsubscribe', { endpoint: subscription.endpoint });
await subscription.unsubscribe();
}
set({ isSubscribed: false });
} catch (error) {
console.error('Failed to unsubscribe from push:', error);
}
},
}));