mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 04:22:57 +00:00
feat(messages): implement integrated chat with voice/calls and tidy root go module
Add Discord-like messaging APIs, websocket realtime, smart suggestions, password vault flows, semantic indexing integration, and new /app/messages UI. Add typing indicators, advanced message search filters, voice notes, browser-local optional transcription, and WebRTC call signaling (offer/answer/ice/hangup). Clean root go.mod via go mod tidy and remove stale root go.sum.
This commit is contained in:
@@ -27,6 +27,7 @@ import { AuthCallback } from '@/pages/AuthCallback'
|
||||
import { AuthProvider } from '@/lib/auth'
|
||||
import { Search } from '@/pages/Search'
|
||||
import { Analytics } from '@/pages/Analytics'
|
||||
import { Messages } from '@/pages/Messages'
|
||||
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
|
||||
import { onMount } from 'solid-js'
|
||||
|
||||
@@ -168,6 +169,13 @@ function App() {
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/messages" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Messages" fullBleed>
|
||||
<Messages />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/members" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Members">
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
IconBrandGithub,
|
||||
IconClock,
|
||||
IconCalendar,
|
||||
IconMessageCircle,
|
||||
IconLogout,
|
||||
IconBuilding,
|
||||
IconPlus,
|
||||
@@ -33,6 +34,7 @@ const navigation = [
|
||||
{ name: 'Calendar', href: '/app/calendar', icon: IconCalendar },
|
||||
{ name: 'Files', href: '/app/files', icon: IconFolder },
|
||||
{ name: 'Notes', href: '/app/notes', icon: IconNotebook },
|
||||
{ name: 'Messages', href: '/app/messages', icon: IconMessageCircle },
|
||||
{ name: 'YouTube', href: '/app/youtube', icon: IconVideo },
|
||||
{ name: 'Members', href: '/app/members', icon: IconUsers },
|
||||
{ name: 'Learning', href: '/app/learning-paths', icon: IconSchool },
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
export type ConversationType = 'global' | 'team' | 'group' | 'dm' | 'self' | 'password_vault';
|
||||
|
||||
export interface UserLite {
|
||||
id: number;
|
||||
username: string;
|
||||
full_name?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: number;
|
||||
type: ConversationType;
|
||||
name: string;
|
||||
topic?: string;
|
||||
team_id?: number | null;
|
||||
created_by: number;
|
||||
is_default: boolean;
|
||||
is_archived: boolean;
|
||||
last_message_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ConversationListItem {
|
||||
conversation: Conversation;
|
||||
role: string;
|
||||
unread_count: number;
|
||||
last_message?: Message;
|
||||
}
|
||||
|
||||
export interface ConversationMember {
|
||||
id: number;
|
||||
conversation_id: number;
|
||||
user_id: number;
|
||||
role: 'owner' | 'admin' | 'member' | 'viewer' | string;
|
||||
joined_at?: string;
|
||||
last_read_message_id?: number | null;
|
||||
last_read_at?: string | null;
|
||||
muted_until?: string | null;
|
||||
is_hidden?: boolean;
|
||||
user?: UserLite;
|
||||
}
|
||||
|
||||
export interface MessageAttachment {
|
||||
id: number;
|
||||
message_id: number;
|
||||
kind: string;
|
||||
file_id?: number | null;
|
||||
url?: string;
|
||||
title?: string;
|
||||
preview_json?: string;
|
||||
}
|
||||
|
||||
export interface MessageReference {
|
||||
id: number;
|
||||
message_id: number;
|
||||
entity_type: string;
|
||||
entity_id: number;
|
||||
deep_link: string;
|
||||
}
|
||||
|
||||
export interface MessageSuggestion {
|
||||
id: number;
|
||||
message_id: number;
|
||||
type: string;
|
||||
payload_json: string;
|
||||
status: 'pending' | 'accepted' | 'dismissed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MessageReaction {
|
||||
id: number;
|
||||
message_id: number;
|
||||
user_id: number;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
conversation_id: number;
|
||||
sender_id: number;
|
||||
sender?: UserLite;
|
||||
body: string;
|
||||
is_sensitive: boolean;
|
||||
edited_at?: string | null;
|
||||
deleted_at?: string | null;
|
||||
metadata_json?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
attachments?: MessageAttachment[];
|
||||
references?: MessageReference[];
|
||||
suggestions?: MessageSuggestion[];
|
||||
reactions?: MessageReaction[];
|
||||
}
|
||||
|
||||
export interface VaultItem {
|
||||
id: number;
|
||||
label: string;
|
||||
owner_user_id: number;
|
||||
source_message_id?: number | null;
|
||||
last_accessed_at?: string | null;
|
||||
shared: boolean;
|
||||
allow_reveal: boolean;
|
||||
expires_at?: string | null;
|
||||
target_conversation_id?: number | null;
|
||||
}
|
||||
|
||||
export interface WsEvent {
|
||||
type: string;
|
||||
conversation_id?: number;
|
||||
data?: any;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
||||
}
|
||||
|
||||
async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE_URL}/api/v1/messages${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Request failed (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const messagesApi = {
|
||||
listConversations: () => apiRequest<{ conversations: ConversationListItem[] }>('/conversations'),
|
||||
createConversation: (payload: any) => apiRequest<{ conversation: Conversation }>('/conversations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
getConversation: (id: number) =>
|
||||
apiRequest<{ conversation: Conversation; membership: ConversationMember; members: ConversationMember[] }>(
|
||||
`/conversations/${id}`
|
||||
),
|
||||
getMessages: (conversationId: number, cursor?: number, limit: number = 50) =>
|
||||
apiRequest<{ messages: Message[]; next_cursor?: number }>(
|
||||
`/conversations/${conversationId}/messages?limit=${limit}${cursor ? `&cursor=${cursor}` : ''}`
|
||||
),
|
||||
sendMessage: (conversationId: number, payload: any) => apiRequest<{ message: Message; warning?: string }>(
|
||||
`/conversations/${conversationId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
),
|
||||
updateMessage: (id: number, body: string) =>
|
||||
apiRequest<{ message: Message }>(`/messages/${id}`, { method: 'PATCH', body: JSON.stringify({ body }) }),
|
||||
deleteMessage: (id: number) => apiRequest<{ message: string }>(`/messages/${id}`, { method: 'DELETE' }),
|
||||
addReaction: (id: number, emoji: string) =>
|
||||
apiRequest<{ reaction: MessageReaction }>(`/messages/${id}/reactions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ emoji }),
|
||||
}),
|
||||
removeReaction: (id: number, emoji: string) =>
|
||||
apiRequest<{ message: string }>(`/messages/${id}/reactions/${encodeURIComponent(emoji)}`, { method: 'DELETE' }),
|
||||
searchMessages: (payload: any) =>
|
||||
apiRequest<{ results: Message[]; total: number; limit: number; offset: number }>(
|
||||
'/messages/search',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
),
|
||||
getSuggestions: (messageId: number) => apiRequest<{ suggestions: MessageSuggestion[] }>(`/messages/${messageId}/suggestions`),
|
||||
acceptSuggestion: (messageId: number, suggestionId: number, payload: any = {}) =>
|
||||
apiRequest<any>(`/messages/${messageId}/suggestions/${suggestionId}/accept`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
dismissSuggestion: (messageId: number, suggestionId: number) =>
|
||||
apiRequest<any>(`/messages/${messageId}/suggestions/${suggestionId}/dismiss`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
listVaultItems: () => apiRequest<{ items: VaultItem[] }>('/password-vault/items'),
|
||||
createVaultItem: (payload: any) => apiRequest<any>('/password-vault/items', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
shareVaultItem: (id: number, payload: any) =>
|
||||
apiRequest<any>(`/password-vault/items/${id}/share`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
revealVaultItem: (id: number) =>
|
||||
apiRequest<{ id: number; label: string; secret: string; notes: string; warning?: string }>(
|
||||
`/password-vault/items/${id}/reveal`,
|
||||
{ method: 'POST', body: JSON.stringify({}) }
|
||||
),
|
||||
unshareVaultItem: (id: number, payload: any) =>
|
||||
apiRequest<any>(`/password-vault/items/${id}/unshare`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
};
|
||||
|
||||
export async function uploadChatFile(file: File): Promise<{ id: number; original_name: string; mime_type: string }> {
|
||||
const token = getToken();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('description', 'Uploaded from chat');
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/v1/files/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Upload failed (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export class MessagesRealtimeClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: number | null = null;
|
||||
private shouldReconnect = true;
|
||||
private onEvent: (event: WsEvent) => void;
|
||||
private onStatus?: (status: 'connected' | 'disconnected' | 'error') => void;
|
||||
|
||||
constructor(
|
||||
onEvent: (event: WsEvent) => void,
|
||||
onStatus?: (status: 'connected' | 'disconnected' | 'error') => void
|
||||
) {
|
||||
this.onEvent = onEvent;
|
||||
this.onStatus = onStatus;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.cleanupReconnect();
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
|
||||
const wsBase = API_BASE_URL.replace(/^http/, 'ws');
|
||||
this.ws = new WebSocket(`${wsBase}/api/v1/messages/ws?token=${encodeURIComponent(token)}`);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.onStatus?.('connected');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (evt) => {
|
||||
try {
|
||||
const parsed = JSON.parse(evt.data) as WsEvent;
|
||||
this.onEvent(parsed);
|
||||
} catch {
|
||||
// ignore invalid payloads
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.onStatus?.('error');
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.onStatus?.('disconnected');
|
||||
this.ws = null;
|
||||
if (this.shouldReconnect) {
|
||||
this.reconnectTimer = window.setTimeout(() => this.connect(), 2000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
send(payload: any) {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
this.cleanupReconnect();
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupReconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user