{isEnvDemoMode() ? 'Demo Mode' : (isLogin() ? 'Welcome back' : 'Create your account')}
@@ -189,7 +238,7 @@ export const Login = () => {
id="username"
type="text"
required
- value={(formData() as RegisterRequest).username}
+ value={formData().username}
onInput={(e) => handleInputChange('username', e.currentTarget.value)}
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
placeholder="username"
@@ -204,7 +253,7 @@ export const Login = () => {
id="fullName"
type="text"
required
- value={(formData() as RegisterRequest).fullName}
+ value={formData().fullName}
onInput={(e) => handleInputChange('fullName', e.currentTarget.value)}
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
placeholder="Your Name"
@@ -239,7 +288,7 @@ export const Login = () => {
- {!registrationDisabled() && (
+ {!registrationDisabled() && !noAccountsExist() && (
{isLogin() ? "Don't have an account?" : 'Already have an account?'}
{
const [members, setMembers] = createSignal([]);
const [showAddModal, setShowAddModal] = createSignal(false);
- const [showEditModal, setShowEditModal] = createSignal(false);
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
- const [editingMember, setEditingMember] = createSignal(null);
const [deletingMember, setDeletingMember] = createSignal(null);
+ const [workspaceId, setWorkspaceId] = createSignal('');
+ const [isLoading, setIsLoading] = createSignal(true);
- const handleAddMember = (memberData: Omit) => {
- const newMember: Member = {
- ...memberData,
- id: Date.now().toString(),
- avatar: memberData.name.split(' ').map(n => n[0]).join('').toUpperCase(),
- joinedAt: 'Just now'
- };
- setMembers(prev => [...prev, newMember]);
- setShowAddModal(false);
+ const getToken = () => localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
+
+ const toRoleLabel = (role: string) => {
+ if (role === 'owner') return 'Owner';
+ if (role === 'admin') return 'Admin';
+ if (role === 'viewer') return 'Viewer';
+ return 'Member';
};
- const handleEditMember = (memberData: Omit) => {
- if (!editingMember()) return;
-
- setMembers(prev =>
- prev.map(m =>
- m.id === editingMember()!.id
- ? {
- ...m,
- ...memberData,
- avatar: memberData.name.split(' ').map(n => n[0]).join('').toUpperCase()
- }
- : m
- )
- );
- setShowEditModal(false);
- setEditingMember(null);
+ const toInitials = (name: string) => {
+ return name
+ .split(' ')
+ .map((part) => part[0] || '')
+ .join('')
+ .slice(0, 2)
+ .toUpperCase();
};
- const openEditModal = (member: Member) => {
- setEditingMember(member);
- setShowEditModal(true);
+ const resolveWorkspaceId = async (): Promise => {
+ const storedWorkspaceId = localStorage.getItem('trackeep_workspace_id') || '';
+ if (storedWorkspaceId) {
+ return storedWorkspaceId;
+ }
+
+ const token = getToken();
+ if (!token) {
+ return '';
+ }
+
+ const teamsResponse = await fetch(`${API_BASE_URL}/teams`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!teamsResponse.ok) {
+ return '';
+ }
+
+ const teamsData = await teamsResponse.json();
+ const teams = Array.isArray(teamsData?.teams) ? teamsData.teams : [];
+ if (teams.length === 0) {
+ return '';
+ }
+
+ const firstTeamId = String(teams[0].id);
+ localStorage.setItem('trackeep_workspace_id', firstTeamId);
+ localStorage.setItem('trackeep_workspace_name', teams[0].name || 'Trackeep Workspace');
+ return firstTeamId;
+ };
+
+ const loadMembers = async () => {
+ setIsLoading(true);
+ try {
+ const token = getToken();
+ if (!token) {
+ setMembers([]);
+ setWorkspaceId('');
+ return;
+ }
+
+ const currentWorkspaceId = await resolveWorkspaceId();
+ setWorkspaceId(currentWorkspaceId);
+
+ if (!currentWorkspaceId) {
+ setMembers([]);
+ return;
+ }
+
+ const response = await fetch(`${API_BASE_URL}/teams/${currentWorkspaceId}/members`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch members: ${response.status}`);
+ }
+
+ const data = await response.json();
+ const membersPayload = Array.isArray(data?.members) ? data.members : [];
+
+ const mappedMembers: Member[] = membersPayload.map((member: any, index: number) => {
+ const user = member.user || {};
+ const name = user.full_name || user.username || user.email || `User ${index + 1}`;
+ const email = user.email || '';
+ return {
+ id: String(member.user_id || user.id || member.id || index + 1),
+ name,
+ email,
+ role: toRoleLabel(member.role || 'member'),
+ avatar: toInitials(name),
+ joinedAt: member.joined_at ? new Date(member.joined_at).toLocaleDateString() : '',
+ };
+ });
+
+ setMembers(mappedMembers);
+ } catch (error) {
+ console.error('Failed to load members:', error);
+ setMembers([]);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleAddMember = async (memberData: { name: string; email: string; role: 'Admin' | 'Member' }) => {
+ const token = getToken();
+ const currentWorkspaceId = workspaceId();
+ if (!token || !currentWorkspaceId) {
+ return;
+ }
+
+ try {
+ const response = await fetch(`${API_BASE_URL}/teams/${currentWorkspaceId}/invite`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ email: memberData.email,
+ role: memberData.role === 'Admin' ? 'admin' : 'member',
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to invite member: ${response.status}`);
+ }
+
+ setShowAddModal(false);
+ alert('Invitation sent successfully.');
+ } catch (error) {
+ console.error('Failed to invite member:', error);
+ alert('Failed to invite member.');
+ }
};
const openDeleteModal = (member: Member) => {
@@ -60,58 +166,56 @@ export const Members = () => {
setShowDeleteModal(true);
};
- const handleDeleteMember = () => {
- if (!deletingMember()) return;
-
- setMembers(prev => prev.filter(m => m.id !== deletingMember()!.id));
- setShowDeleteModal(false);
- setDeletingMember(null);
- };
+ const handleDeleteMember = async () => {
+ const member = deletingMember();
+ const token = getToken();
+ const currentWorkspaceId = workspaceId();
+ if (!member || !token || !currentWorkspaceId) {
+ return;
+ }
- const handleToggleRole = (member: Member) => {
- const newRole = member.role === 'Admin' ? 'Member' : 'Admin';
- setMembers(prev =>
- prev.map(m =>
- m.id === member.id ? { ...m, role: newRole } : m
- )
- );
+ try {
+ const response = await fetch(`${API_BASE_URL}/teams/${currentWorkspaceId}/members/${member.id}`, {
+ method: 'DELETE',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to remove member: ${response.status}`);
+ }
+
+ setMembers((prev) => prev.filter((entry) => entry.id !== member.id));
+ setShowDeleteModal(false);
+ setDeletingMember(null);
+ } catch (error) {
+ console.error('Failed to remove member:', error);
+ alert('Failed to remove member.');
+ }
};
onMount(() => {
- // Mock data
- setMembers([
- {
- id: '1',
- name: 'John Doe',
- email: 'john@example.com',
- role: 'Admin',
- avatar: 'JD',
- joinedAt: '2 weeks ago'
- },
- {
- id: '2',
- name: 'Jane Smith',
- email: 'jane@example.com',
- role: 'Member',
- avatar: 'JS',
- joinedAt: '1 month ago'
- },
- {
- id: '3',
- name: 'Bob Johnson',
- email: 'bob@example.com',
- role: 'Member',
- avatar: 'BJ',
- joinedAt: '3 months ago'
- }
- ]);
+ void loadMembers();
+
+ const onWorkspaceChanged = () => {
+ void loadMembers();
+ };
+
+ window.addEventListener('trackeep:workspace-changed', onWorkspaceChanged);
+ onCleanup(() => window.removeEventListener('trackeep:workspace-changed', onWorkspaceChanged));
});
return (
Members
-
setShowAddModal(true)}>
+ setShowAddModal(true)}
+ disabled={!workspaceId()}
+ >
Add Member
@@ -128,72 +232,68 @@ export const Members = () => {
- {members().map((member) => (
-
-
-
-
- {member.avatar}
-
-
-
{member.name}
-
{member.email}
-
-
-
-
-
- {member.role}
-
-
-
- {member.joinedAt}
-
-
-
-
-
-
- }
- >
- openEditModal(member)} icon={IconEdit}>
- Edit
-
- handleToggleRole(member)} icon={member.role === 'Admin' ? IconShieldCheck : IconShield}>
- {member.role === 'Admin' ? 'Make Member' : 'Make Admin'}
-
- openDeleteModal(member)} icon={IconTrash} variant="destructive">
- Remove
-
-
-
+ {isLoading() ? (
+
+
+ Loading members...
- ))}
+ ) : members().length === 0 ? (
+
+
+ No members yet.
+
+
+ ) : (
+ members().map((member) => (
+
+
+
+
+ {member.avatar}
+
+
+
{member.name}
+
{member.email}
+
+
+
+
+
+ {member.role}
+
+
+
+ {member.joinedAt || 'Unknown'}
+
+
+
+
+
+
+ }
+ >
+ openDeleteModal(member)} icon={IconTrash} variant="destructive">
+ Remove
+
+
+
+
+
+ ))
+ )}
- {/* Modals */}
setShowAddModal(false)}
onSubmit={handleAddMember}
/>
- {
- setShowEditModal(false);
- setEditingMember(null);
- }}
- onSubmit={handleEditMember}
- member={editingMember()}
- isEdit={true}
- />
-
{
diff --git a/frontend/src/pages/Messages.css b/frontend/src/pages/Messages.css
new file mode 100644
index 0000000..ea0f6ec
--- /dev/null
+++ b/frontend/src/pages/Messages.css
@@ -0,0 +1,623 @@
+.messages-shell {
+ height: 100%;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ min-height: 0;
+ background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 70%, hsl(var(--muted) / 0.18) 100%);
+}
+
+.messages-shell-list .messages-main {
+ display: none;
+}
+
+.messages-shell-conversation .messages-sidebar {
+ display: none;
+}
+
+.messages-sidebar {
+ border-inline: 1px solid hsl(var(--border));
+ background: hsl(var(--card));
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ width: min(100%, 980px);
+ margin: 0 auto;
+}
+
+.messages-sidebar-header {
+ padding: 0.9rem;
+ border-bottom: 1px solid hsl(var(--border));
+ display: grid;
+ gap: 0.6rem;
+}
+
+.messages-title-row,
+.messages-sidebar-actions,
+.messages-status-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.messages-title-wrap {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.messages-title {
+ font-size: 1.02rem;
+ font-weight: 650;
+}
+
+.messages-status-row {
+ font-size: 0.7rem;
+ color: hsl(var(--muted-foreground));
+ letter-spacing: 0.02em;
+}
+
+.messages-sidebar-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.55rem;
+ display: grid;
+ gap: 0.35rem;
+ align-content: start;
+}
+
+.messages-list-empty {
+ border: 1px dashed hsl(var(--border));
+ border-radius: 0.72rem;
+ background: hsl(var(--muted) / 0.3);
+ padding: 1rem;
+ text-align: center;
+ font-size: 0.82rem;
+ color: hsl(var(--muted-foreground));
+}
+
+.conversation-item {
+ border: 1px solid transparent;
+ border-radius: 0.72rem;
+ padding: 0.58rem 0.65rem;
+ text-align: left;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ transition: background-color 120ms ease, border-color 120ms ease;
+}
+
+.conversation-item:hover {
+ background: hsl(var(--muted) / 0.6);
+}
+
+.conversation-item-active {
+ background: hsl(var(--primary) / 0.14);
+ border-color: hsl(var(--primary) / 0.45);
+}
+
+.conversation-item-main {
+ min-width: 0;
+}
+
+.conversation-item-name {
+ font-size: 0.84rem;
+ font-weight: 620;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.conversation-item-preview {
+ font-size: 0.72rem;
+ color: hsl(var(--muted-foreground));
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.conversation-item-unread {
+ min-width: 1.2rem;
+ height: 1.2rem;
+ border-radius: 999px;
+ background: hsl(var(--primary));
+ color: hsl(var(--primary-foreground));
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.68rem;
+ font-weight: 650;
+ padding: 0 0.25rem;
+}
+
+.messages-main {
+ width: min(100%, 1180px);
+ margin: 0 auto;
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto auto auto minmax(0, 1fr) auto;
+ border-inline: 1px solid hsl(var(--border));
+ background: hsl(var(--card));
+}
+
+.messages-main-header {
+ border-bottom: 1px solid hsl(var(--border));
+ padding: 0.85rem 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.messages-header-main {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ min-width: 0;
+}
+
+.messages-back-button {
+ flex-shrink: 0;
+}
+
+.messages-header-meta {
+ min-width: 0;
+}
+
+.messages-header-title {
+ font-size: 1rem;
+ font-weight: 650;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.messages-header-subtitle {
+ color: hsl(var(--muted-foreground));
+ font-size: 0.72rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.messages-header-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.messages-main-empty {
+ display: grid;
+ place-items: center;
+ color: hsl(var(--muted-foreground));
+ font-size: 0.85rem;
+ padding: 1.5rem;
+}
+
+.messages-call-strip,
+.messages-transcript-preview {
+ padding: 0.55rem 1rem;
+ font-size: 0.75rem;
+ color: hsl(var(--muted-foreground));
+ border-bottom: 1px solid hsl(var(--border));
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.8rem;
+}
+
+.messages-timeline {
+ padding: 1rem;
+ overflow-y: auto;
+ display: grid;
+ gap: 0.8rem;
+}
+
+.message-row {
+ display: flex;
+}
+
+.message-row-me {
+ justify-content: flex-end;
+}
+
+.message-row-them {
+ justify-content: flex-start;
+}
+
+.message-bubble {
+ max-width: min(76%, 900px);
+ border-radius: 0.95rem;
+ border: 1px solid hsl(var(--border));
+ padding: 0.68rem 0.74rem;
+ box-shadow: 0 1px 2px hsl(0 0% 0% / 0.08);
+}
+
+.message-bubble-me {
+ background: hsl(var(--primary) / 0.17);
+ border-color: hsl(var(--primary) / 0.48);
+}
+
+.message-bubble-them {
+ background: hsl(var(--card));
+}
+
+.message-meta {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-size: 0.72rem;
+ color: hsl(var(--muted-foreground));
+ margin-bottom: 0.35rem;
+}
+
+.message-avatar {
+ width: 1.4rem;
+ height: 1.4rem;
+ border-radius: 999px;
+ overflow: hidden;
+ background: hsl(var(--muted));
+ color: hsl(var(--foreground));
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.62rem;
+ font-weight: 700;
+}
+
+.message-time {
+ margin-left: auto;
+}
+
+.message-edited {
+ font-size: 0.63rem;
+}
+
+.message-body {
+ white-space: pre-wrap;
+ word-break: break-word;
+ line-height: 1.32rem;
+ font-size: 0.9rem;
+}
+
+.message-sensitive-banner {
+ margin-top: 0.55rem;
+ border-radius: 0.52rem;
+ padding: 0.42rem 0.5rem;
+ border: 1px solid hsl(var(--warning) / 0.4);
+ background: hsl(var(--warning) / 0.12);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ font-size: 0.73rem;
+}
+
+.message-attachments {
+ margin-top: 0.6rem;
+ display: grid;
+ gap: 0.4rem;
+}
+
+.message-attachment-link,
+.message-voice-note {
+ border: 1px solid hsl(var(--border));
+ border-radius: 0.55rem;
+ padding: 0.45rem 0.55rem;
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-size: 0.75rem;
+ text-decoration: none;
+}
+
+.message-attachment-link:hover {
+ background: hsl(var(--muted) / 0.55);
+}
+
+.message-voice-note {
+ flex-direction: column;
+ align-items: flex-start;
+}
+
+.message-reference-wrap {
+ margin-top: 0.5rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.message-reference-pill {
+ border-radius: 999px;
+ border: 1px solid hsl(var(--border));
+ padding: 0.14rem 0.45rem;
+ font-size: 0.68rem;
+ color: hsl(var(--muted-foreground));
+ text-decoration: none;
+}
+
+.message-suggestions {
+ margin-top: 0.6rem;
+ display: grid;
+ gap: 0.45rem;
+}
+
+.message-suggestion-card {
+ border: 1px solid hsl(var(--border));
+ border-radius: 0.55rem;
+ padding: 0.45rem;
+ background: hsl(var(--muted) / 0.35);
+}
+
+.message-suggestion-title {
+ font-size: 0.72rem;
+ text-transform: capitalize;
+ margin-bottom: 0.35rem;
+}
+
+.message-suggestion-actions {
+ display: flex;
+ gap: 0.4rem;
+}
+
+.message-reaction-panel {
+ margin-top: 0.62rem;
+ display: grid;
+ gap: 0.35rem;
+}
+
+.message-reaction-add-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.2rem;
+}
+
+.reaction-add-btn {
+ width: 1.6rem;
+ height: 1.6rem;
+ border-radius: 0.4rem;
+ border: 1px solid transparent;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: hsl(var(--muted-foreground));
+}
+
+.reaction-add-btn:hover {
+ border-color: hsl(var(--border));
+ background: hsl(var(--muted) / 0.55);
+ color: hsl(var(--foreground));
+}
+
+.message-reaction-summary {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+}
+
+.reaction-pill {
+ border-radius: 999px;
+ border: 1px solid hsl(var(--border));
+ padding: 0.15rem 0.45rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.68rem;
+ background: hsl(var(--card));
+}
+
+.reaction-pill-me {
+ border-color: hsl(var(--primary) / 0.55);
+ background: hsl(var(--primary) / 0.16);
+}
+
+.messages-composer {
+ position: relative;
+ border-top: 1px solid hsl(var(--border));
+ padding: 0.72rem 1rem 0.9rem;
+ display: grid;
+ gap: 0.5rem;
+ background: hsl(var(--card));
+}
+
+.messages-composer-drag {
+ background: hsl(var(--primary) / 0.08);
+}
+
+.messages-typing-line {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-size: 0.73rem;
+ color: hsl(var(--muted-foreground));
+}
+
+.typing-dots {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.22rem;
+}
+
+.typing-dots span {
+ width: 0.28rem;
+ height: 0.28rem;
+ border-radius: 999px;
+ background: hsl(var(--primary));
+ animation: typingBounce 1.1s infinite ease-in-out;
+}
+
+.typing-dots span:nth-child(2) {
+ animation-delay: 0.14s;
+}
+
+.typing-dots span:nth-child(3) {
+ animation-delay: 0.28s;
+}
+
+@keyframes typingBounce {
+ 0%, 80%, 100% {
+ transform: translateY(0);
+ opacity: 0.4;
+ }
+ 40% {
+ transform: translateY(-2px);
+ opacity: 1;
+ }
+}
+
+.composer-chip-wrap {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.composer-chip {
+ border-radius: 999px;
+ border: 1px solid hsl(var(--border));
+ background: hsl(var(--muted) / 0.45);
+ padding: 0.18rem 0.36rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.28rem;
+ font-size: 0.68rem;
+}
+
+.composer-chip-remove {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+}
+
+.messages-recording-line {
+ color: hsl(var(--destructive));
+ font-size: 0.73rem;
+ font-weight: 600;
+}
+
+.messages-composer-row {
+ display: grid;
+ grid-template-columns: auto auto minmax(0, 1fr) auto;
+ gap: 0.45rem;
+ align-items: end;
+}
+
+.messages-composer-input-wrap {
+ position: relative;
+}
+
+.messages-composer-textarea {
+ min-height: 2.6rem;
+ max-height: 9rem;
+ width: 100%;
+ resize: none;
+ border: 1px solid hsl(var(--border));
+ border-radius: 0.62rem;
+ background: hsl(var(--background));
+ padding: 0.58rem 0.64rem;
+ font-size: 0.88rem;
+ line-height: 1.25rem;
+}
+
+.messages-composer-textarea:focus {
+ outline: none;
+ border-color: hsl(var(--primary));
+ box-shadow: 0 0 0 2px hsl(var(--primary) / 0.15);
+}
+
+.mention-menu {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: calc(100% + 0.4rem);
+ border: 1px solid hsl(var(--border));
+ border-radius: 0.65rem;
+ background: hsl(var(--card));
+ box-shadow: 0 8px 26px hsl(0 0% 0% / 0.24);
+ max-height: 16rem;
+ overflow-y: auto;
+ z-index: 20;
+}
+
+.mention-menu-empty {
+ padding: 0.55rem 0.65rem;
+ color: hsl(var(--muted-foreground));
+ font-size: 0.74rem;
+}
+
+.mention-option {
+ width: 100%;
+ text-align: left;
+ border: none;
+ background: transparent;
+ padding: 0.52rem 0.62rem;
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.mention-option-active,
+.mention-option:hover {
+ background: hsl(var(--muted) / 0.65);
+}
+
+.mention-option-copy {
+ min-width: 0;
+}
+
+.mention-option-title {
+ font-size: 0.79rem;
+ font-weight: 620;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.mention-option-sub {
+ font-size: 0.68rem;
+ color: hsl(var(--muted-foreground));
+}
+
+.messages-composer-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.7rem;
+}
+
+.messages-inline-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.72rem;
+ color: hsl(var(--muted-foreground));
+}
+
+@media (max-width: 980px) {
+ .messages-sidebar,
+ .messages-main {
+ width: 100%;
+ border-inline: none;
+ }
+
+ .message-bubble {
+ max-width: 86%;
+ }
+}
+
+@media (max-width: 760px) {
+ .messages-main {
+ grid-template-rows: auto auto auto minmax(0, 1fr) auto;
+ }
+
+ .messages-composer-row {
+ grid-template-columns: auto auto 1fr;
+ }
+
+ .messages-composer-row > button:last-child {
+ grid-column: 3;
+ justify-self: end;
+ }
+}
diff --git a/frontend/src/pages/Messages.tsx b/frontend/src/pages/Messages.tsx
index 2e786ce..19523a2 100644
--- a/frontend/src/pages/Messages.tsx
+++ b/frontend/src/pages/Messages.tsx
@@ -1,7 +1,6 @@
import { createSignal, For, Show, onCleanup, onMount } from 'solid-js';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
-import { Card } from '@/components/ui/Card';
import { toast } from '@/components/ui/Toast';
import {
MessagesRealtimeClient,
@@ -13,19 +12,32 @@ import type {
ConversationMember,
Message,
MessageSuggestion,
- VaultItem,
+ UserFile,
} from '@/lib/messages';
import {
+ IconAt,
IconBell,
IconBellOff,
- IconLock,
+ IconBolt,
+ IconChevronLeft,
+ IconCircleCheck,
+ IconFile,
+ IconHeart,
IconMessageCircle,
+ IconMicrophone,
+ IconMicrophoneOff,
+ IconPaperclip,
+ IconPhone,
+ IconPhoneOff,
IconPlus,
IconSearch,
IconSend,
- IconUsers,
+ IconSparkles,
+ IconThumbUp,
+ IconUpload,
IconX,
} from '@tabler/icons-solidjs';
+import './Messages.css';
interface MemberOption {
id: number;
@@ -33,6 +45,42 @@ interface MemberOption {
name: string;
}
+interface TeamOption {
+ id: number;
+ name: string;
+}
+
+interface MentionUserOption {
+ type: 'user';
+ id: number;
+ username: string;
+ label: string;
+}
+
+interface MentionFileOption {
+ type: 'file';
+ file: UserFile;
+ label: string;
+}
+
+type MentionOption = MentionUserOption | MentionFileOption;
+
+interface ComposerLibraryFile {
+ id: number;
+ original_name: string;
+ mime_type: string;
+}
+
+type ReactionKey = 'thumb_up' | 'heart' | 'bolt' | 'check' | 'sparkles';
+
+const REACTION_PRESETS: Array<{ key: ReactionKey; label: string; icon: any }> = [
+ { key: 'thumb_up', label: 'Thumb up', icon: IconThumbUp },
+ { key: 'heart', label: 'Heart', icon: IconHeart },
+ { key: 'bolt', label: 'Bolt', icon: IconBolt },
+ { key: 'check', label: 'Check', icon: IconCircleCheck },
+ { key: 'sparkles', label: 'Sparkles', icon: IconSparkles },
+];
+
const ATTACHMENT_KIND_OPTIONS = [
'file',
'image',
@@ -57,7 +105,6 @@ const REFERENCE_TYPE_OPTIONS = [
'learning_path',
'saved_search',
'github',
- 'password_vault_item',
];
type TriStateFilter = 'any' | 'yes' | 'no';
@@ -67,6 +114,7 @@ export const Messages = () => {
const [conversations, setConversations] = createSignal([]);
const [messages, setMessages] = createSignal([]);
const [selectedConversationId, setSelectedConversationId] = createSignal(null);
+ const [activeScreen, setActiveScreen] = createSignal<'list' | 'conversation'>('list');
const [loadingConversations, setLoadingConversations] = createSignal(false);
const [loadingMessages, setLoadingMessages] = createSignal(false);
const [inputText, setInputText] = createSignal('');
@@ -77,19 +125,16 @@ export const Messages = () => {
const [searchQuery, setSearchQuery] = createSignal('');
const [searchResults, setSearchResults] = createSignal([]);
const [searching, setSearching] = createSignal(false);
- const [showVault, setShowVault] = createSignal(false);
- const [vaultItems, setVaultItems] = createSignal([]);
- const [revealedSecrets, setRevealedSecrets] = createSignal>({});
- const [shareTargets, setShareTargets] = createSignal>({});
const [members, setMembers] = createSignal([]);
+ const [teams, setTeams] = createSignal([]);
const [conversationMembers, setConversationMembers] = createSignal([]);
const [typingByConversation, setTypingByConversation] = createSignal>>({});
const [newConversationType, setNewConversationType] = createSignal<'dm' | 'group' | 'team'>('dm');
const [newConversationName, setNewConversationName] = createSignal('');
const [newConversationTopic, setNewConversationTopic] = createSignal('');
- const [targetUserId, setTargetUserId] = createSignal('');
- const [groupUserIds, setGroupUserIds] = createSignal('');
- const [teamId, setTeamId] = createSignal('');
+ const [targetUserId, setTargetUserId] = createSignal(null);
+ const [groupUserIds, setGroupUserIds] = createSignal([]);
+ const [teamId, setTeamId] = createSignal(null);
const [showCreateConversation, setShowCreateConversation] = createSignal(false);
const [browserNotificationsEnabled, setBrowserNotificationsEnabled] = createSignal(
localStorage.getItem('messages_browser_notifications') === 'true'
@@ -118,6 +163,15 @@ export const Messages = () => {
localStorage.getItem('messages_call_transcript') !== 'false'
);
const [callTranscriptPreview, setCallTranscriptPreview] = createSignal('');
+ const [attachedLibraryFiles, setAttachedLibraryFiles] = createSignal([]);
+ const [mentionQuery, setMentionQuery] = createSignal('');
+ const [mentionOpen, setMentionOpen] = createSignal(false);
+ const [mentionOptions, setMentionOptions] = createSignal([]);
+ const [mentionHighlightedIndex, setMentionHighlightedIndex] = createSignal(0);
+ const [mentionLoading, setMentionLoading] = createSignal(false);
+ const [isDragOverComposer, setIsDragOverComposer] = createSignal(false);
+ const [revealedSensitiveMessages, setRevealedSensitiveMessages] = createSignal>({});
+ const [uploadProgress, setUploadProgress] = createSignal<{ done: number; total: number } | null>(null);
const getCurrentUserId = () => {
const raw = localStorage.getItem('trackeep_user') || localStorage.getItem('user');
@@ -151,6 +205,11 @@ export const Messages = () => {
let callRecognition: any = null;
let callFinalTranscript = '';
let callInterimTranscript = '';
+ let mentionSearchTimer: number | null = null;
+ let mentionSearchToken = 0;
+ let composerTextareaRef: HTMLTextAreaElement | null = null;
+ let hiddenFileInputRef: HTMLInputElement | null = null;
+ const sensitiveRevealTimers = new Map();
const sortedConversations = () =>
[...conversations()].sort((a, b) => {
@@ -162,6 +221,61 @@ export const Messages = () => {
const activeConversation = () =>
conversations().find((item) => item.conversation.id === selectedConversationId()) || null;
+ const openConversation = (conversationId: number) => {
+ setSelectedConversationId(conversationId);
+ setActiveScreen('conversation');
+ };
+
+ const closeConversation = () => {
+ const conversationID = selectedConversationId();
+ if (conversationID) {
+ markTypingStopped(conversationID);
+ }
+ if (isCallActive()) {
+ endVoiceCall(true);
+ }
+ setActiveScreen('list');
+ };
+
+ const shouldUseSensitiveReveal = () => {
+ const type = activeConversation()?.conversation.type;
+ return type === 'dm' || type === 'self';
+ };
+
+ const conversationNameById = (id: number) =>
+ conversations().find((item) => item.conversation.id === id)?.conversation.name || 'Conversation';
+
+ const normalizeReactionKey = (value: string): ReactionKey => {
+ const raw = value.trim().toLowerCase();
+ if (!raw) return 'thumb_up';
+ if (raw === 'thumb_up' || raw === '👍' || raw === ':+1:') return 'thumb_up';
+ if (raw === 'heart' || raw === '❤️' || raw === '❤') return 'heart';
+ if (raw === 'bolt' || raw === '🔥') return 'bolt';
+ if (raw === 'check' || raw === '✅') return 'check';
+ if (raw === 'sparkles' || raw === '✨' || raw === '⭐' || raw === 'star') return 'sparkles';
+ return 'thumb_up';
+ };
+
+ const reactionIconForKey = (key: ReactionKey) =>
+ REACTION_PRESETS.find((preset) => preset.key === key)?.icon || IconThumbUp;
+
+ const groupedReactions = (message: Message) => {
+ const grouped = new Map();
+ for (const reaction of message.reactions || []) {
+ const key = normalizeReactionKey(reaction.emoji);
+ const current = grouped.get(key) || { count: 0, mine: false, rawMine: [] };
+ current.count += 1;
+ if (reaction.user_id === currentUserId()) {
+ current.mine = true;
+ current.rawMine.push(reaction.emoji);
+ }
+ grouped.set(key, current);
+ }
+ return grouped;
+ };
+
+ const reactionEntries = (message: Message) => Array.from(groupedReactions(message).entries());
+
const getSpeechRecognitionCtor = () => {
if (typeof window === 'undefined') return null;
const w = window as any;
@@ -222,12 +336,75 @@ export const Messages = () => {
.filter((id) => id !== currentUserId())
.map((id) => {
const member = conversationMembers().find((m) => m.user_id === id);
- return member?.user?.full_name || member?.user?.username || `User ${id}`;
+ return member?.user?.full_name || member?.user?.username || 'Unknown user';
});
return names;
};
+ const activeMentionToken = (text: string, caret: number) => {
+ const beforeCaret = text.slice(0, caret);
+ const atIndex = beforeCaret.lastIndexOf('@');
+ if (atIndex < 0) return null;
+ if (atIndex > 0 && /\S/.test(beforeCaret.charAt(atIndex - 1))) {
+ return null;
+ }
+
+ const token = beforeCaret.slice(atIndex + 1);
+ if (/\s/.test(token)) return null;
+
+ return {
+ query: token.trim().toLowerCase(),
+ start: atIndex,
+ end: caret,
+ };
+ };
+
+ const refreshMentionOptions = (query: string) => {
+ const conversationUserOptions: MentionUserOption[] = conversationMembers()
+ .map((member) => {
+ const username = (member.user?.username || '').trim();
+ const label = member.user?.full_name || username || 'Unknown user';
+ return {
+ type: 'user' as const,
+ id: member.user_id,
+ username: username || label.toLowerCase().replace(/\s+/g, '.'),
+ label,
+ };
+ })
+ .filter((option) => option.id !== currentUserId())
+ .filter((option, index, arr) => arr.findIndex((entry) => entry.id === option.id) === index)
+ .filter((option) =>
+ !query ? true : `${option.label} ${option.username}`.toLowerCase().includes(query)
+ )
+ .slice(0, 6);
+
+ setMentionLoading(true);
+ const token = ++mentionSearchToken;
+ void messagesApi
+ .listUserFiles(query, 8)
+ .then((files) => {
+ if (token !== mentionSearchToken) return;
+ const fileOptions: MentionFileOption[] = (files || []).map((file) => ({
+ type: 'file',
+ file,
+ label: file.original_name || 'Attachment',
+ }));
+ const merged: MentionOption[] = [...conversationUserOptions, ...fileOptions];
+ setMentionOptions(merged);
+ setMentionHighlightedIndex(0);
+ })
+ .catch(() => {
+ if (token !== mentionSearchToken) return;
+ setMentionOptions(conversationUserOptions);
+ })
+ .finally(() => {
+ if (token === mentionSearchToken) {
+ setMentionLoading(false);
+ }
+ });
+ };
+
const requestNotificationPermission = async () => {
if (!('Notification' in window)) {
toast.warning('Browser notifications unavailable', 'This browser does not support notifications.');
@@ -430,10 +607,6 @@ export const Messages = () => {
const created = response.message;
setMessages((prev) => (prev.some((m) => m.id === created.id) ? prev : [...prev, created]));
- if (response.warning) {
- toast.warning('Sensitive Content Warning', response.warning);
- }
-
loadConversations();
} catch (error) {
throw error;
@@ -660,8 +833,8 @@ export const Messages = () => {
}
setActiveCallConversationId(conversationID);
- if (selectedConversationId() !== conversationID) {
- setSelectedConversationId(conversationID);
+ if (selectedConversationId() !== conversationID || activeScreen() !== 'conversation') {
+ openConversation(conversationID);
}
const pc = buildPeerConnection(conversationID, senderID);
@@ -800,9 +973,17 @@ export const Messages = () => {
setLoadingConversations(true);
try {
const data = await messagesApi.listConversations();
- setConversations(data.conversations || []);
- if (!selectedConversationId() && data.conversations?.length) {
- setSelectedConversationId(data.conversations[0].conversation.id);
+ const nextConversations = data.conversations || [];
+ setConversations(nextConversations);
+
+ const selectedID = selectedConversationId();
+ if (selectedID && !nextConversations.some((item) => item.conversation.id === selectedID)) {
+ setSelectedConversationId(null);
+ setActiveScreen('list');
+ }
+ if (nextConversations.length === 0) {
+ setSelectedConversationId(null);
+ setActiveScreen('list');
}
} catch (error) {
toast.error('Failed to load conversations', error instanceof Error ? error.message : 'Unknown error');
@@ -843,7 +1024,7 @@ export const Messages = () => {
const mapped: MemberOption[] = (data.members || []).map((m: any) => ({
id: Number(m.id),
username: m.username || '',
- name: m.name || m.full_name || m.email || `User ${m.id}`,
+ name: m.name || m.full_name || m.username || m.email || 'Member',
}));
setMembers(mapped);
} catch {
@@ -851,12 +1032,21 @@ export const Messages = () => {
}
};
- const loadVaultItems = async () => {
+ const loadTeams = async () => {
+ const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
try {
- const data = await messagesApi.listVaultItems();
- setVaultItems(data.items || []);
- } catch (error) {
- toast.error('Failed to load vault items', error instanceof Error ? error.message : 'Unknown error');
+ const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/teams?limit=200`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ });
+ if (!res.ok) return;
+ const data = await res.json();
+ const mapped: TeamOption[] = (data.teams || []).map((team: any) => ({
+ id: Number(team.id),
+ name: team.name || 'Team',
+ }));
+ setTeams(mapped.filter((team) => Number.isFinite(team.id) && team.id > 0));
+ } catch {
+ // ignore
}
};
@@ -1011,17 +1201,116 @@ export const Messages = () => {
const onFileSelect = (event: Event) => {
const target = event.currentTarget as HTMLInputElement;
const files = Array.from(target.files || []);
- setSelectedFiles(files);
+ if (files.length > 0) {
+ setSelectedFiles((prev) => [...prev, ...files]);
+ }
+ if (target) {
+ target.value = '';
+ }
+ };
+
+ const removeLocalFile = (index: number) => {
+ setSelectedFiles((prev) => prev.filter((_, fileIndex) => fileIndex !== index));
+ };
+
+ const removeAttachedLibraryFile = (id: number) => {
+ setAttachedLibraryFiles((prev) => prev.filter((file) => file.id !== id));
+ };
+
+ const selectMentionOption = (option: MentionOption, range: { start: number; end: number }) => {
+ const currentText = inputText();
+ let inserted = '';
+
+ if (option.type === 'user') {
+ inserted = `@${option.username} `;
+ } else {
+ const readableFileName = (option.file.original_name || 'file').trim() || 'file';
+ inserted = `@${readableFileName.replace(/\s+/g, '_')} `;
+ setAttachedLibraryFiles((prev) =>
+ prev.some((entry) => entry.id === option.file.id)
+ ? prev
+ : [
+ ...prev,
+ {
+ id: option.file.id,
+ original_name: option.file.original_name || 'Attachment',
+ mime_type: option.file.mime_type,
+ },
+ ]
+ );
+ }
+
+ const updated = `${currentText.slice(0, range.start)}${inserted}${currentText.slice(range.end)}`;
+ setInputText(updated);
+ setMentionOpen(false);
+ setMentionOptions([]);
+ setMentionQuery('');
+
+ window.requestAnimationFrame(() => {
+ if (!composerTextareaRef) return;
+ const caret = range.start + inserted.length;
+ composerTextareaRef.focus();
+ composerTextareaRef.setSelectionRange(caret, caret);
+ });
+ };
+
+ const handleComposerInput = (event: InputEvent & { currentTarget: HTMLTextAreaElement }) => {
+ const value = event.currentTarget.value;
+ const caret = event.currentTarget.selectionStart || value.length;
+ setInputText(value);
+
+ if (value.trim()) {
+ markTypingStarted();
+ } else {
+ markTypingStopped();
+ }
+
+ const mentionToken = activeMentionToken(value, caret);
+ if (!mentionToken) {
+ setMentionOpen(false);
+ setMentionOptions([]);
+ setMentionQuery('');
+ return;
+ }
+
+ const nextQuery = mentionToken.query;
+ setMentionOpen(true);
+ setMentionQuery(nextQuery);
+
+ if (mentionSearchTimer) {
+ window.clearTimeout(mentionSearchTimer);
+ mentionSearchTimer = null;
+ }
+ mentionSearchTimer = window.setTimeout(() => {
+ refreshMentionOptions(nextQuery);
+ }, 120);
+ };
+
+ const handleComposerDrop = (event: DragEvent) => {
+ event.preventDefault();
+ setIsDragOverComposer(false);
+ const droppedFiles = Array.from(event.dataTransfer?.files || []);
+ if (droppedFiles.length > 0) {
+ setSelectedFiles((prev) => [...prev, ...droppedFiles]);
+ }
};
const sendMessage = async () => {
if (!selectedConversationId()) return;
const body = inputText().trim();
- if (!body && selectedFiles().length === 0) return;
+ if (!body && selectedFiles().length === 0 && attachedLibraryFiles().length === 0) return;
try {
+ const localFiles = [...selectedFiles()];
+ if (localFiles.length > 0) {
+ setUploadProgress({ done: 0, total: localFiles.length });
+ } else {
+ setUploadProgress(null);
+ }
+
const attachments: any[] = [];
- for (const file of selectedFiles()) {
+ for (let i = 0; i < localFiles.length; i += 1) {
+ const file = localFiles[i];
const uploaded = await uploadChatFile(file);
attachments.push({
kind: uploaded.mime_type?.startsWith('image/') ? 'image' : 'file',
@@ -1029,13 +1318,29 @@ export const Messages = () => {
title: uploaded.original_name,
url: `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/files/${uploaded.id}/download`,
});
+ setUploadProgress({ done: i + 1, total: localFiles.length });
+ }
+
+ for (const file of attachedLibraryFiles()) {
+ attachments.push({
+ kind: file.mime_type?.startsWith('image/') ? 'image' : 'file',
+ file_id: file.id,
+ title: file.original_name,
+ url: `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/files/${file.id}/download`,
+ });
}
await postMessage(body, attachments);
setInputText('');
setSelectedFiles([]);
+ setAttachedLibraryFiles([]);
+ setMentionOptions([]);
+ setMentionOpen(false);
+ setMentionQuery('');
} catch (error) {
toast.error('Failed to send message', error instanceof Error ? error.message : 'Unknown error');
+ } finally {
+ setUploadProgress(null);
}
};
@@ -1052,9 +1357,6 @@ export const Messages = () => {
if (selectedConversationId()) {
await loadMessages(selectedConversationId()!);
}
- if (suggestion.type === 'move_to_password_vault') {
- await loadVaultItems();
- }
} catch (error) {
toast.error('Failed to apply suggestion', error instanceof Error ? error.message : 'Unknown error');
}
@@ -1068,6 +1370,53 @@ export const Messages = () => {
}
};
+ const removeReaction = async (messageId: number, emoji: string) => {
+ try {
+ await messagesApi.removeReaction(messageId, emoji);
+ } catch {
+ // ignore reaction errors in quick UI
+ }
+ };
+
+ const toggleReaction = async (message: Message, key: ReactionKey) => {
+ const grouped = groupedReactions(message);
+ const group = grouped.get(key);
+ if (group?.mine) {
+ for (const raw of group.rawMine) {
+ await removeReaction(message.id, raw);
+ }
+ return;
+ }
+ await addReaction(message.id, key);
+ };
+
+ const revealSensitiveMessage = async (messageId: number) => {
+ try {
+ const data = await messagesApi.revealSensitiveMessage(messageId);
+ setRevealedSensitiveMessages((prev) => ({
+ ...prev,
+ [messageId]: data.plaintext,
+ }));
+
+ const existing = sensitiveRevealTimers.get(messageId);
+ if (existing) {
+ window.clearTimeout(existing);
+ }
+
+ const timer = window.setTimeout(() => {
+ setRevealedSensitiveMessages((prev) => {
+ const next = { ...prev };
+ delete next[messageId];
+ return next;
+ });
+ sensitiveRevealTimers.delete(messageId);
+ }, 15000);
+ sensitiveRevealTimers.set(messageId, timer);
+ } catch (error) {
+ toast.error('Reveal failed', error instanceof Error ? error.message : 'Unable to reveal message');
+ }
+ };
+
const performSearch = async () => {
setSearching(true);
try {
@@ -1095,7 +1444,7 @@ export const Messages = () => {
};
const openSearchResult = async (result: Message) => {
- setSelectedConversationId(result.conversation_id);
+ openConversation(result.conversation_id);
setSearchOpen(false);
await loadMessages(result.conversation_id);
};
@@ -1110,65 +1459,42 @@ export const Messages = () => {
};
if (type === 'dm') {
- payload.user_ids = [Number(targetUserId())];
+ if (!targetUserId()) {
+ toast.warning('Select a member', 'Choose who to message directly.');
+ return;
+ }
+ payload.user_ids = [targetUserId()];
} else if (type === 'group') {
- payload.user_ids = groupUserIds()
- .split(',')
- .map((part) => Number(part.trim()))
- .filter((id) => Number.isFinite(id) && id > 0);
+ if (groupUserIds().length === 0) {
+ toast.warning('Select members', 'Choose at least one member for the group.');
+ return;
+ }
+ payload.user_ids = groupUserIds();
} else if (type === 'team') {
- payload.team_id = Number(teamId());
+ if (!teamId()) {
+ toast.warning('Select a team', 'Choose a team to create the channel.');
+ return;
+ }
+ payload.team_id = teamId();
}
const data = await messagesApi.createConversation(payload);
setShowCreateConversation(false);
setNewConversationName('');
setNewConversationTopic('');
- setTargetUserId('');
- setGroupUserIds('');
- setTeamId('');
+ setTargetUserId(null);
+ setGroupUserIds([]);
+ setTeamId(null);
await loadConversations();
- setSelectedConversationId(data.conversation.id);
+ openConversation(data.conversation.id);
await loadMessages(data.conversation.id);
} catch (error) {
toast.error('Failed to create conversation', error instanceof Error ? error.message : 'Unknown error');
}
};
- const revealVaultItem = async (item: VaultItem) => {
- try {
- const revealed = await messagesApi.revealVaultItem(item.id);
- setRevealedSecrets((prev) => ({
- ...prev,
- [item.id]: { secret: revealed.secret, notes: revealed.notes || '' },
- }));
- toast.warning('Password Safety', revealed.warning || 'Handle revealed secrets with care.');
- } catch (error) {
- toast.error('Failed to reveal vault item', error instanceof Error ? error.message : 'Unknown error');
- }
- };
-
- const shareVaultItem = async (item: VaultItem) => {
- const targetRaw = shareTargets()[item.id];
- const targetConversationID = Number(targetRaw);
- if (!targetConversationID) {
- toast.warning('Target required', 'Enter a valid conversation ID.');
- return;
- }
- try {
- await messagesApi.shareVaultItem(item.id, {
- target_conversation_id: targetConversationID,
- allow_reveal: true,
- });
- toast.success('Vault item shared');
- await loadVaultItems();
- } catch (error) {
- toast.error('Failed to share vault item', error instanceof Error ? error.message : 'Unknown error');
- }
- };
-
onMount(async () => {
- await Promise.all([loadConversations(), loadMembers(), loadVaultItems()]);
+ await Promise.all([loadConversations(), loadMembers(), loadTeams()]);
startRealtime();
typingCleanupTimer = window.setInterval(() => {
const cutoff = Date.now() - 6000;
@@ -1192,11 +1518,35 @@ export const Messages = () => {
}, 1500);
});
+ onMount(() => {
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape') return;
+ if (searchOpen()) {
+ setSearchOpen(false);
+ }
+ if (showCreateConversation()) {
+ setShowCreateConversation(false);
+ }
+ if (mentionOpen()) {
+ setMentionOpen(false);
+ }
+ };
+
+ window.addEventListener('keydown', handleEscape);
+ onCleanup(() => {
+ window.removeEventListener('keydown', handleEscape);
+ });
+ });
+
onCleanup(() => {
markTypingStopped();
discardVoiceRecording = true;
stopVoiceRecording();
cleanupCallResources();
+ if (mentionSearchTimer) {
+ window.clearTimeout(mentionSearchTimer);
+ mentionSearchTimer = null;
+ }
if (typingCleanupTimer) {
window.clearInterval(typingCleanupTimer);
typingCleanupTimer = null;
@@ -1205,6 +1555,10 @@ export const Messages = () => {
window.clearTimeout(typingStopTimer);
typingStopTimer = null;
}
+ for (const timer of Array.from(sensitiveRevealTimers.values())) {
+ window.clearTimeout(timer);
+ }
+ sensitiveRevealTimers.clear();
stopPollingFallback();
realtime?.disconnect();
});
@@ -1243,23 +1597,22 @@ export const Messages = () => {
onCleanup(() => clearInterval(wsWatcher));
return (
-