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
+36
View File
@@ -0,0 +1,36 @@
import { create } from 'zustand';
import type { UserStatus } from './auth.ts';
export interface PresenceEntry {
userId: string;
username: string;
status: UserStatus;
}
interface PresenceState {
presences: Record<string, PresenceEntry>;
/** Upsert a single user's presence from a WS event. */
updatePresence: (userId: string, username: string, status: UserStatus) => void;
/** Get a user's current presence; defaults to offline. */
getPresence: (userId: string) => PresenceEntry;
}
const DEFAULT_PRESENCE: PresenceEntry = {
userId: '',
username: '',
status: 'offline',
};
export const usePresenceStore = create<PresenceState>((set, get) => ({
presences: {},
updatePresence: (userId, username, status) =>
set((state) => ({
presences: {
...state.presences,
[userId]: { userId, username, status },
},
})),
getPresence: (userId) => get().presences[userId] ?? DEFAULT_PRESENCE,
}));
+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);
}
},
}));
+55
View File
@@ -0,0 +1,55 @@
import { create } from 'zustand';
import { useWebSocketStore } from './ws';
interface TypingUser {
userId: string;
username: string;
channelId: string;
timeout: ReturnType<typeof setTimeout>;
}
interface TypingState {
typingUsers: Record<string, TypingUser[]>;
sendTypingStart: (channelId: string) => void;
_handleTypingEvent: (data: { channel_id: string; user_id: string; username: string }) => void;
}
export const useTypingStore = create<TypingState>((set, get) => ({
typingUsers: {},
sendTypingStart: (channelId) => {
const ws = useWebSocketStore.getState();
if (ws.connected) {
ws.send({ type: 'TYPING_START', payload: { channel_id: channelId } });
}
},
_handleTypingEvent: (data) => {
const { channel_id, user_id, username } = data;
const existing = get().typingUsers[channel_id] || [];
const existingUser = existing.find(u => u.userId === user_id);
if (existingUser) {
clearTimeout(existingUser.timeout);
}
const timeout = setTimeout(() => {
set(state => ({
typingUsers: {
...state.typingUsers,
[channel_id]: (state.typingUsers[channel_id] || []).filter(u => u.userId !== user_id),
},
}));
}, 3000);
set(state => ({
typingUsers: {
...state.typingUsers,
[channel_id]: [
...(state.typingUsers?.[channel_id] || []).filter((u: { userId: string }) => u.userId !== user_id),
{ userId: user_id, username, channelId: channel_id, timeout },
],
},
}));
},
}));
+17
View File
@@ -2,9 +2,12 @@ import { create } from 'zustand';
import { useMessageStore } from './message.ts';
import { useChannelStore } from './channel.ts';
import { useServerStore } from './server.ts';
import { usePresenceStore } from './presence.ts';
import { useTypingStore } from './typing.ts';
import type { Message } from './message.ts';
import type { Channel } from './channel.ts';
import type { Server } from './server.ts';
import type { UserStatus } from './auth.ts';
type UnknownPayload = Record<string, unknown>;
@@ -147,6 +150,20 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
if (server) updateServer(server);
break;
}
case 'PRESENCE_UPDATE': {
const { user_id, username, status } = data.payload as Record<string, string>;
if (user_id && username && status) {
usePresenceStore.getState().updatePresence(user_id, username, status as UserStatus);
}
break;
}
case 'TYPING_START': {
const { channel_id, user_id, username } = data.payload as Record<string, string>;
if (channel_id && user_id && username) {
useTypingStore.getState()._handleTypingEvent({ channel_id, user_id, username });
}
break;
}
default:
break;
}