Compare commits
2 Commits
effaf64bcf
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| eafd12758a | |||
| 80a217fc5b |
338
docs/superpowers/plans/2026-04-18-near-term-chat-features.md
Normal file
338
docs/superpowers/plans/2026-04-18-near-term-chat-features.md
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# Near-Term Chat Features Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Use this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Prefer small, verified slices over one large migration.
|
||||||
|
|
||||||
|
**Goal:** Implement the seven highest-impact improvements from the modern chat backlog: direct messages, unread counts and notifications, message editing/reactions/replies, user search, pagination, room membership/private rooms, and stronger token/input safety.
|
||||||
|
|
||||||
|
**Architecture:** Move Oxyde from public global rooms toward permissioned conversations. Introduce room membership and room kind first, then layer direct messages, unread state, richer message metadata, search, pagination, notifications, and security hardening on top. Frontend state should keep room summaries separate from loaded message pages so sidebar updates do not force full message reloads.
|
||||||
|
|
||||||
|
**Tech Stack:** SvelteKit 5, TypeScript, Tauri 2, Rust, SurrealDB 3, Tauri plugins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delivery Order
|
||||||
|
|
||||||
|
1. Room membership and private rooms.
|
||||||
|
2. User search instead of raw user IDs.
|
||||||
|
3. Direct messages.
|
||||||
|
4. Message pagination and scroll behavior.
|
||||||
|
5. Unread counts and notifications.
|
||||||
|
6. Message editing, reactions, and replies.
|
||||||
|
7. Secure token storage and validation limits.
|
||||||
|
|
||||||
|
This order reduces rework: direct messages and unread counts both depend on membership; richer messages are easier after pagination and room summaries are in place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Data Model Target
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `room` | Conversation container. Add `kind`, `name`, `created_by`, `created`, `updated`. |
|
||||||
|
| `room_member` | Membership and per-user room state. Stores `room`, `user`, `role`, `joined`, `last_read_at`, `muted`. |
|
||||||
|
| `message` | Message body and metadata. Add `updated`, `deleted`, `reply_to`. |
|
||||||
|
| `message_reaction` | One reaction per user/message/emoji. |
|
||||||
|
| `contact` | Existing contact graph. Keep, then improve with search/request flows later. |
|
||||||
|
|
||||||
|
### Permission Rules
|
||||||
|
|
||||||
|
- Users can select rooms only when they are members.
|
||||||
|
- Users can select messages only for rooms where they are members.
|
||||||
|
- Users can create messages only in rooms where they are members.
|
||||||
|
- Users can update/delete only their own messages.
|
||||||
|
- Users can select room members only for rooms where they are members.
|
||||||
|
- Direct-message rooms should only include the two participants.
|
||||||
|
|
||||||
|
### Suggested Models
|
||||||
|
|
||||||
|
Update `src-tauri/src/models.rs` and `src/lib/types.ts` to include:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface Room {
|
||||||
|
id: any;
|
||||||
|
name?: string;
|
||||||
|
kind: 'public' | 'private' | 'direct';
|
||||||
|
created_by?: any;
|
||||||
|
created: string;
|
||||||
|
updated?: string;
|
||||||
|
last_message?: Message;
|
||||||
|
unread_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomMember {
|
||||||
|
id: any;
|
||||||
|
room: any;
|
||||||
|
user: any;
|
||||||
|
role: 'owner' | 'member';
|
||||||
|
joined: string;
|
||||||
|
last_read_at?: string;
|
||||||
|
muted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: any;
|
||||||
|
room: any;
|
||||||
|
author: any;
|
||||||
|
author_username?: string;
|
||||||
|
body: string;
|
||||||
|
created: string;
|
||||||
|
updated?: string;
|
||||||
|
deleted?: boolean;
|
||||||
|
reply_to?: any;
|
||||||
|
reactions?: MessageReactionSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageReactionSummary {
|
||||||
|
emoji: string;
|
||||||
|
count: number;
|
||||||
|
reacted_by_me: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `surreal/schema.surql` | Add membership, richer message fields, reaction table, indexes, permissions, validation. |
|
||||||
|
| `src-tauri/src/models.rs` | Add `RoomMember`, richer `Room`, richer `Message`, reaction models, room summary structs. |
|
||||||
|
| `src/lib/types.ts` | Mirror backend types for frontend state. |
|
||||||
|
| `src-tauri/src/commands/chat.rs` | Add membership-aware room/message queries, pagination, edit/reply/reaction/read commands. |
|
||||||
|
| `src-tauri/src/commands/user.rs` | Add user search and eventually credential/profile validation improvements. |
|
||||||
|
| `src-tauri/src/commands/mod.rs` | Register any new command modules if split. |
|
||||||
|
| `src-tauri/src/lib.rs` | Register new commands and notification/keychain plugins if used. |
|
||||||
|
| `src/routes/+page.svelte` | Split room summaries, active room, message page state, unread updates, notification hooks. |
|
||||||
|
| `src/lib/components/Sidebar.svelte` | User search, direct-message entry points, unread counts, private room UI. |
|
||||||
|
| `src/lib/components/ChatMain.svelte` | Pagination, edit UI, reply UI, reactions, read markers. |
|
||||||
|
| `src/lib/components/AuthCard.svelte` | Validation and copy changes if encryption wording changes. |
|
||||||
|
| `src/lib/helpers.ts` | Add date/cursor helpers and user display helpers as needed. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Room Membership And Private Rooms
|
||||||
|
|
||||||
|
**Goal:** Replace global room visibility with explicit membership. Support public rooms as joinable conversations and private rooms as invite-only conversations.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `surreal/schema.surql`
|
||||||
|
- Modify: `src-tauri/src/models.rs`
|
||||||
|
- Modify: `src/lib/types.ts`
|
||||||
|
- Modify: `src-tauri/src/commands/chat.rs`
|
||||||
|
- Modify: `src/routes/+page.svelte`
|
||||||
|
- Modify: `src/lib/components/Sidebar.svelte`
|
||||||
|
|
||||||
|
- [ ] Add `kind` to `room`: `public`, `private`, `direct`.
|
||||||
|
- [ ] Add `created_by` and `updated` to `room`.
|
||||||
|
- [ ] Add `room_member` table with `room`, `user`, `role`, `joined`, `last_read_at`, `muted`.
|
||||||
|
- [ ] Add unique index on `room_member(room, user)`.
|
||||||
|
- [ ] Update room permissions so `select` requires membership, except optionally public room discovery.
|
||||||
|
- [ ] Update message permissions so `select` and `create` require room membership.
|
||||||
|
- [ ] Update `create_room` to create the room and insert the creator as owner/member in one command.
|
||||||
|
- [ ] Update `get_rooms` to return only rooms where the current user is a member.
|
||||||
|
- [ ] Add `join_public_room(room_id)` if public room discovery remains available.
|
||||||
|
- [ ] Add `invite_to_room(room_id, user_id)` for owner/member invitation, depending on chosen rules.
|
||||||
|
- [ ] Add UI affordances for private/public room creation.
|
||||||
|
- [ ] Verify old public-room behavior still works for rooms the user creates.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- A user cannot see rooms they are not a member of.
|
||||||
|
- A user cannot fetch or send messages in rooms they are not a member of.
|
||||||
|
- Creating a room makes the creator a member.
|
||||||
|
- Existing room list and message send flows still work for the creator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: User Search Instead Of Raw User IDs
|
||||||
|
|
||||||
|
**Goal:** Let users find people by username or email instead of manually copying record IDs.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `surreal/schema.surql`
|
||||||
|
- Modify: `src-tauri/src/commands/user.rs`
|
||||||
|
- Modify: `src-tauri/src/lib.rs`
|
||||||
|
- Modify: `src/lib/types.ts`
|
||||||
|
- Modify: `src/lib/components/Sidebar.svelte`
|
||||||
|
- Modify: `src/routes/+page.svelte`
|
||||||
|
|
||||||
|
- [ ] Add indexes for searchable fields, at least `username`; keep `email` unique.
|
||||||
|
- [ ] Add validation and privacy rules for what search returns.
|
||||||
|
- [ ] Add `search_users(query: String) -> Vec<UserSearchResult>`.
|
||||||
|
- [ ] Exclude the current user from search results.
|
||||||
|
- [ ] Return safe user fields only: id, username, avatar, maybe email only if product rules allow it.
|
||||||
|
- [ ] Replace add-contact raw ID field with a search box and selectable results.
|
||||||
|
- [ ] Use selected search result IDs for `add_contact`, `invite_to_room`, and direct-message creation.
|
||||||
|
- [ ] Add debouncing in the frontend so search does not run on every keystroke immediately.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Contacts can be added without knowing a raw SurrealDB record ID.
|
||||||
|
- Empty/short searches do not spam the backend.
|
||||||
|
- Search results do not expose password/token/internal fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Direct Messages
|
||||||
|
|
||||||
|
**Goal:** Add one-to-one conversations that behave like rooms but are created from contacts or user search.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `surreal/schema.surql`
|
||||||
|
- Modify: `src-tauri/src/models.rs`
|
||||||
|
- Modify: `src-tauri/src/commands/chat.rs`
|
||||||
|
- Modify: `src/lib/types.ts`
|
||||||
|
- Modify: `src/routes/+page.svelte`
|
||||||
|
- Modify: `src/lib/components/Sidebar.svelte`
|
||||||
|
- Modify: `src/lib/components/ChatMain.svelte`
|
||||||
|
|
||||||
|
- [ ] Add `room.kind = 'direct'`.
|
||||||
|
- [ ] Decide direct room naming: no stored name, display as other participant's username.
|
||||||
|
- [ ] Add a stable uniqueness guard for direct rooms. Use a deterministic participant key if SurrealDB indexes cannot enforce two-member uniqueness directly.
|
||||||
|
- [ ] Add `get_or_create_direct_room(user_id) -> Room`.
|
||||||
|
- [ ] Insert both participants into `room_member` when creating a direct room.
|
||||||
|
- [ ] Add command/query support to hydrate direct-room display names and avatars.
|
||||||
|
- [ ] Add "Message" action to contacts/search results.
|
||||||
|
- [ ] Show direct messages in a separate sidebar section or mixed with rooms using clear labels.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Starting a DM with the same user opens the existing direct room.
|
||||||
|
- Both participants can see and send messages in the DM.
|
||||||
|
- No third user can read or join the DM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Pagination And Scroll Behavior
|
||||||
|
|
||||||
|
**Goal:** Avoid loading every message at once and preserve a stable reading experience.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src-tauri/src/commands/chat.rs`
|
||||||
|
- Modify: `src/lib/types.ts`
|
||||||
|
- Modify: `src/routes/+page.svelte`
|
||||||
|
- Modify: `src/lib/components/ChatMain.svelte`
|
||||||
|
- Modify: `src/lib/helpers.ts`
|
||||||
|
|
||||||
|
- [ ] Change `get_messages(room_id)` into a paginated command, for example `get_messages(room_id, before?: datetime, limit?: number)`.
|
||||||
|
- [ ] Return messages newest-page aware but render oldest-to-newest in the UI.
|
||||||
|
- [ ] Add an index on `message(room, created)`.
|
||||||
|
- [ ] Track `hasOlderMessages`, `isLoadingOlder`, and `oldestCursor` in page state.
|
||||||
|
- [ ] Load older messages when the user scrolls near the top.
|
||||||
|
- [ ] Preserve scroll offset after prepending older messages.
|
||||||
|
- [ ] Only auto-scroll to bottom when the user is already near the bottom or the message is authored by the current user.
|
||||||
|
- [ ] Keep the live subscription for new messages in the active room.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Opening a room loads a bounded number of messages.
|
||||||
|
- Scrolling upward loads older messages without jumping.
|
||||||
|
- New incoming messages do not force-scroll users who are reading history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Unread Counts And Notifications
|
||||||
|
|
||||||
|
**Goal:** Make missed messages visible in the sidebar and via desktop notifications when appropriate.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `surreal/schema.surql`
|
||||||
|
- Modify: `src-tauri/src/models.rs`
|
||||||
|
- Modify: `src-tauri/src/commands/chat.rs`
|
||||||
|
- Modify: `src-tauri/src/lib.rs`
|
||||||
|
- Modify: `src/lib/types.ts`
|
||||||
|
- Modify: `src/routes/+page.svelte`
|
||||||
|
- Modify: `src/lib/components/Sidebar.svelte`
|
||||||
|
|
||||||
|
- [ ] Add or use `room_member.last_read_at`.
|
||||||
|
- [ ] Add `mark_room_read(room_id)` command.
|
||||||
|
- [ ] Update room summary query to include `last_message` and `unread_count`.
|
||||||
|
- [ ] Mark the active room read when opened and when the user reaches the bottom.
|
||||||
|
- [ ] Increment/update unread room summaries when live events arrive for inactive rooms.
|
||||||
|
- [ ] Add visual unread badges in the sidebar.
|
||||||
|
- [ ] Add Tauri notification plugin if not already available.
|
||||||
|
- [ ] Request notification permission at a sensible moment.
|
||||||
|
- [ ] Send native notifications for messages in inactive rooms when the app is unfocused and the room is not muted.
|
||||||
|
- [ ] Add `muted` support from `room_member.muted` to suppress notifications.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Inactive room messages increase unread count.
|
||||||
|
- Opening or reading a room clears its unread count for the current user.
|
||||||
|
- Desktop notifications fire only when useful and respect muted rooms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Message Editing, Reactions, And Replies
|
||||||
|
|
||||||
|
**Goal:** Add the message interactions users expect without disrupting the current simple composer.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `surreal/schema.surql`
|
||||||
|
- Modify: `src-tauri/src/models.rs`
|
||||||
|
- Modify: `src-tauri/src/commands/chat.rs`
|
||||||
|
- Modify: `src/lib/types.ts`
|
||||||
|
- Modify: `src/routes/+page.svelte`
|
||||||
|
- Modify: `src/lib/components/ChatMain.svelte`
|
||||||
|
- Modify: `src/lib/components/ContextMenu.svelte` if richer menu state is needed.
|
||||||
|
|
||||||
|
- [ ] Add `updated`, `deleted`, and `reply_to` fields to `message`.
|
||||||
|
- [ ] Replace hard delete with soft delete for normal message deletion.
|
||||||
|
- [ ] Add `edit_message(message_id, body)` command with author-only permission.
|
||||||
|
- [ ] Add `send_message(room_id, body, reply_to?)`.
|
||||||
|
- [ ] Add `message_reaction` table with `message`, `user`, `emoji`, `created`.
|
||||||
|
- [ ] Add unique index on `message_reaction(message, user, emoji)`.
|
||||||
|
- [ ] Add `toggle_reaction(message_id, emoji)` command.
|
||||||
|
- [ ] Include reaction summaries when fetching messages.
|
||||||
|
- [ ] Add context menu actions for edit, reply, delete, copy.
|
||||||
|
- [ ] Add inline edit mode for the user's own messages.
|
||||||
|
- [ ] Add reply preview above the composer and reply reference rendering in the message list.
|
||||||
|
- [ ] Add a small reaction picker or a short default emoji row.
|
||||||
|
- [ ] Ensure live update events update edited messages, deleted messages, and reactions.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Users can edit only their own messages.
|
||||||
|
- Replies show enough context to identify the parent message.
|
||||||
|
- Reactions toggle reliably and aggregate counts across users.
|
||||||
|
- Deleted messages leave a useful placeholder instead of breaking replies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Secure Token Storage And Validation Limits
|
||||||
|
|
||||||
|
**Goal:** Reduce security and data-quality risks before the app grows more social features.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src-tauri/Cargo.toml`
|
||||||
|
- Modify: `src-tauri/src/lib.rs`
|
||||||
|
- Modify: `src-tauri/src/commands/user.rs`
|
||||||
|
- Modify: `src-tauri/src/commands/chat.rs`
|
||||||
|
- Modify: `surreal/schema.surql`
|
||||||
|
- Modify: `src/lib/components/AuthCard.svelte`
|
||||||
|
- Modify: `src/lib/components/Sidebar.svelte`
|
||||||
|
- Modify: `src/lib/components/ChatMain.svelte`
|
||||||
|
|
||||||
|
- [ ] Replace plain `tauri-plugin-store` token persistence with OS-backed secure storage where practical.
|
||||||
|
- [ ] If secure storage is not immediately available on every target platform, isolate token storage behind helper functions so the backend can swap implementations later.
|
||||||
|
- [ ] Add username length and character validation.
|
||||||
|
- [ ] Add email length validation.
|
||||||
|
- [ ] Add password minimum length in signup.
|
||||||
|
- [ ] Add room name length validation.
|
||||||
|
- [ ] Add message body length validation.
|
||||||
|
- [ ] Add avatar URL validation or remove avatar URL until uploads/proxying exist.
|
||||||
|
- [ ] Add SurrealDB schema assertions where possible, and duplicate key user-facing errors in Rust for better messages.
|
||||||
|
- [ ] Remove or revise the auth tagline claim `encrypted` unless end-to-end encryption is implemented.
|
||||||
|
- [ ] Add tests for validation boundaries in Rust command-level helpers where possible.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Session tokens are not stored as plain JSON when a supported secure storage path is available.
|
||||||
|
- Invalid inputs fail before they create malformed records.
|
||||||
|
- Error messages are useful to users.
|
||||||
|
- Product copy no longer overclaims encryption.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
- [ ] Run `pnpm check` after each frontend slice.
|
||||||
|
- [ ] Run `cargo test` or `cargo check` inside `src-tauri` after each Rust slice.
|
||||||
|
- [ ] Manually test with two users: public room, private room, direct message, message send, edit, reply, reaction, unread clear, notification, and signout/session restore.
|
||||||
|
- [ ] Test permission failures by trying to fetch a room/message as a non-member.
|
||||||
|
- [ ] Test scroll pagination with enough messages to require at least three pages.
|
||||||
93
docs/superpowers/specs/2026-04-18-modern-chat-todo.md
Normal file
93
docs/superpowers/specs/2026-04-18-modern-chat-todo.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Modern Chat App Todo
|
||||||
|
|
||||||
|
**Date:** 2026-04-18
|
||||||
|
**Status:** Draft
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Oxyde currently has a compact chat foundation: authentication, persistent session restore, public rooms, live message updates, contacts, profile editing, message delete, and context menus. This backlog lists user-facing improvements that would make it feel closer to a modern desktop chat app.
|
||||||
|
|
||||||
|
## Core Chat
|
||||||
|
|
||||||
|
- [ ] Add message editing, with an edited timestamp or marker.
|
||||||
|
- [ ] Add replies or lightweight threads so users can respond to a specific message.
|
||||||
|
- [ ] Add reactions, starting with emoji reactions on messages.
|
||||||
|
- [ ] Add read receipts or "seen by" state for direct and group conversations.
|
||||||
|
- [ ] Add typing indicators per room.
|
||||||
|
- [ ] Add message pagination or infinite scroll instead of loading every message in a room.
|
||||||
|
- [ ] Add message search across the current room and all rooms.
|
||||||
|
- [ ] Add link previews for URLs in messages.
|
||||||
|
- [ ] Add file and image attachments with preview support.
|
||||||
|
- [ ] Add Markdown-style formatting for code, links, bold text, lists, and multiline blocks.
|
||||||
|
|
||||||
|
## Rooms And Conversations
|
||||||
|
|
||||||
|
- [ ] Add private direct messages between contacts.
|
||||||
|
- [ ] Add room membership instead of fully public rooms.
|
||||||
|
- [ ] Add invite flows for rooms and contacts instead of requiring raw user IDs.
|
||||||
|
- [ ] Add room settings: rename room, delete room, leave room.
|
||||||
|
- [ ] Add pinned messages per room.
|
||||||
|
- [ ] Add room unread counts and last-message previews in the sidebar.
|
||||||
|
- [ ] Add notification badges when messages arrive outside the active room.
|
||||||
|
- [ ] Add muted rooms or per-room notification settings.
|
||||||
|
|
||||||
|
## Contacts And Identity
|
||||||
|
|
||||||
|
- [ ] Replace "add contact by user ID" with user search by username or email.
|
||||||
|
- [ ] Add contact requests and approval instead of immediately adding contacts.
|
||||||
|
- [ ] Show real avatars instead of only username initials.
|
||||||
|
- [ ] Add presence states: online, idle, offline, and do-not-disturb.
|
||||||
|
- [ ] Add profile cards when clicking or right-clicking a user.
|
||||||
|
- [ ] Add account settings for email and password changes.
|
||||||
|
- [ ] Add password reset or recovery flow.
|
||||||
|
|
||||||
|
## Reliability And UX
|
||||||
|
|
||||||
|
- [ ] Add optimistic sending states: sending, sent, failed, retry.
|
||||||
|
- [ ] Add offline handling and reconnect indicators.
|
||||||
|
- [ ] Add local draft persistence per room.
|
||||||
|
- [ ] Preserve scroll position when switching rooms.
|
||||||
|
- [ ] Avoid always auto-scrolling if the user is reading older messages.
|
||||||
|
- [ ] Add empty, error, and loading states for room list, contacts, and messages.
|
||||||
|
- [ ] Add toast notifications for copy, delete, save, and failed actions.
|
||||||
|
- [ ] Add keyboard shortcuts: room switcher, search, focus composer, escape modals.
|
||||||
|
- [ ] Add accessibility pass: focus states, ARIA labels, keyboard context menus.
|
||||||
|
|
||||||
|
## Security And Privacy
|
||||||
|
|
||||||
|
- [ ] Clarify whether "encrypted" is real; the auth screen says encrypted, but messages currently appear stored as plain text.
|
||||||
|
- [ ] Add end-to-end encryption or remove encryption claims until implemented.
|
||||||
|
- [ ] Store session tokens more securely where possible, ideally via OS keychain or credential storage instead of plain app store JSON.
|
||||||
|
- [ ] Add rate limits or abuse protection for room and message creation.
|
||||||
|
- [ ] Add validation and length limits for usernames, room names, avatars, and message bodies.
|
||||||
|
- [ ] Add block and report user flows.
|
||||||
|
|
||||||
|
## Desktop App Polish
|
||||||
|
|
||||||
|
- [ ] Add native notifications for background messages.
|
||||||
|
- [ ] Add tray behavior or "minimize to tray" settings.
|
||||||
|
- [ ] Add app update flow.
|
||||||
|
- [ ] Add deep links or app links for room invites.
|
||||||
|
- [ ] Add platform-specific menu items: preferences, quit, about.
|
||||||
|
- [ ] Add window state persistence: size, position, last active room.
|
||||||
|
- [ ] Add themes, including light, dark, and system options.
|
||||||
|
- [ ] Add responsive layout for narrower windows.
|
||||||
|
|
||||||
|
## Data Model And Backend
|
||||||
|
|
||||||
|
- [ ] Add room membership tables and permissions. Rooms and messages are currently broadly selectable in `surreal/schema.surql`.
|
||||||
|
- [ ] Add message metadata fields like `updated`, `deleted`, `reply_to`, `attachments`, and `reactions`.
|
||||||
|
- [ ] Add indexes for common queries: messages by room and created timestamp, contacts by owner, room memberships.
|
||||||
|
- [ ] Add proper soft delete for messages instead of hard delete.
|
||||||
|
- [ ] Add migrations or versioning for schema changes.
|
||||||
|
- [ ] Add tests around auth permissions, contact visibility, message ownership, and live subscriptions.
|
||||||
|
|
||||||
|
## Near-Term Best Bets
|
||||||
|
|
||||||
|
- [ ] Direct messages.
|
||||||
|
- [ ] Unread counts and notifications.
|
||||||
|
- [ ] Message editing, reactions, and replies.
|
||||||
|
- [ ] User search instead of raw user IDs.
|
||||||
|
- [ ] Pagination or infinite scroll.
|
||||||
|
- [ ] Room membership and private rooms.
|
||||||
|
- [ ] Secure token storage and validation limits.
|
||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -3824,7 +3824,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oxyde"
|
name = "oxyde"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use surrealdb::types::{RecordId, RecordIdKey};
|
||||||
|
use surrealdb::Notification;
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use futures_util::StreamExt;
|
|
||||||
use surrealdb::Notification;
|
|
||||||
|
|
||||||
use crate::db::AppState;
|
use crate::db::AppState;
|
||||||
use crate::error::{into_err, AppError};
|
use crate::error::{into_err, AppError};
|
||||||
use crate::models::{Message, Room};
|
use crate::models::{Message, MessageReaction, MessageReactionSummary, Room, User};
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE: i64 = 50;
|
||||||
|
const MAX_PAGE_SIZE: i64 = 100;
|
||||||
|
const MAX_MESSAGE_LEN: usize = 4000;
|
||||||
|
const MAX_ROOM_NAME_LEN: usize = 80;
|
||||||
|
|
||||||
/// Wrapper emitted to the frontend for each LIVE query notification.
|
/// Wrapper emitted to the frontend for each LIVE query notification.
|
||||||
/// Includes the action type so the frontend can distinguish create/update/delete.
|
/// Includes the action type so the frontend can distinguish create/update/delete.
|
||||||
@@ -15,36 +23,356 @@ struct LiveMessageEvent<'a> {
|
|||||||
data: &'a Message,
|
data: &'a Message,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new chat room.
|
fn validate_room_name(name: &str) -> Result<(), String> {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(AppError::Auth("room name is required".into()).to_string());
|
||||||
|
}
|
||||||
|
if trimmed.chars().count() > MAX_ROOM_NAME_LEN {
|
||||||
|
return Err(AppError::Auth(format!(
|
||||||
|
"room name must be {MAX_ROOM_NAME_LEN} characters or less"
|
||||||
|
))
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_message_body(body: &str) -> Result<(), String> {
|
||||||
|
let trimmed = body.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(AppError::Auth("message cannot be empty".into()).to_string());
|
||||||
|
}
|
||||||
|
if trimmed.chars().count() > MAX_MESSAGE_LEN {
|
||||||
|
return Err(AppError::Auth(format!(
|
||||||
|
"message must be {MAX_MESSAGE_LEN} characters or less"
|
||||||
|
))
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_key_string(id: &RecordId) -> String {
|
||||||
|
match &id.key {
|
||||||
|
RecordIdKey::String(value) => value.clone(),
|
||||||
|
RecordIdKey::Number(value) => value.to_string(),
|
||||||
|
RecordIdKey::Uuid(value) => value.to_string(),
|
||||||
|
other => format!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_record_key(user_id: &str) -> String {
|
||||||
|
let user_id = user_id.trim();
|
||||||
|
if let Some((table, key)) = user_id.split_once(':') {
|
||||||
|
if table == "user" {
|
||||||
|
return format!("user:{key}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("user:{user_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_id_key(user_id: &str) -> String {
|
||||||
|
let user_id = user_id.trim();
|
||||||
|
if let Some((table, key)) = user_id.split_once(':') {
|
||||||
|
if table == "user" {
|
||||||
|
return key.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user_id.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direct_room_key(current_user: &RecordId, target_user_id: &str) -> String {
|
||||||
|
let mut participants = [
|
||||||
|
user_record_key(&record_key_string(current_user)),
|
||||||
|
user_record_key(target_user_id),
|
||||||
|
];
|
||||||
|
participants.sort();
|
||||||
|
participants.join("|")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn current_user(state: &State<'_, AppState>) -> Result<User, String> {
|
||||||
|
let mut result: Vec<User> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT * FROM $auth")
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result
|
||||||
|
.pop()
|
||||||
|
.ok_or_else(|| into_err(AppError::Auth("not authenticated".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hydrate_reactions(
|
||||||
|
state: &State<'_, AppState>,
|
||||||
|
user: &User,
|
||||||
|
messages: &mut [Message],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
for message in messages {
|
||||||
|
let reactions: Vec<MessageReaction> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT * FROM message_reaction WHERE message = $message")
|
||||||
|
.bind(("message", message.id.clone()))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
let mut grouped: HashMap<String, MessageReactionSummary> = HashMap::new();
|
||||||
|
for reaction in reactions {
|
||||||
|
let entry = grouped
|
||||||
|
.entry(reaction.emoji.clone())
|
||||||
|
.or_insert(MessageReactionSummary {
|
||||||
|
emoji: reaction.emoji,
|
||||||
|
count: 0,
|
||||||
|
reacted_by_me: false,
|
||||||
|
});
|
||||||
|
entry.count += 1;
|
||||||
|
if reaction.user == user.id {
|
||||||
|
entry.reacted_by_me = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut summaries: Vec<MessageReactionSummary> = grouped.into_values().collect();
|
||||||
|
summaries.sort_by(|a, b| a.emoji.cmp(&b.emoji));
|
||||||
|
message.reactions = Some(summaries);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hydrate_direct_rooms(
|
||||||
|
state: &State<'_, AppState>,
|
||||||
|
rooms: &mut [Room],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
for room in rooms.iter_mut().filter(|room| room.kind == "direct") {
|
||||||
|
let mut users: Vec<User> = state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"SELECT * FROM user
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT VALUE user FROM room_member
|
||||||
|
WHERE room = $room AND user != $auth
|
||||||
|
)
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(("room", room.id.clone()))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
room.other_user = users.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dedupe_direct_rooms(rooms: Vec<Room>) -> Vec<Room> {
|
||||||
|
let mut seen_direct_users = HashMap::new();
|
||||||
|
let mut deduped = Vec::with_capacity(rooms.len());
|
||||||
|
|
||||||
|
for room in rooms {
|
||||||
|
if room.kind == "direct" {
|
||||||
|
if let Some(other_user) = &room.other_user {
|
||||||
|
let key = user_record_key(&record_key_string(&other_user.id));
|
||||||
|
if seen_direct_users.insert(key, ()).is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deduped.push(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new chat room and add the creator as owner.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn create_room(
|
pub async fn create_room(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
name: String,
|
name: String,
|
||||||
|
kind: Option<String>,
|
||||||
) -> Result<Room, String> {
|
) -> Result<Room, String> {
|
||||||
let mut result: Vec<Room> = state
|
validate_room_name(&name)?;
|
||||||
.db
|
let room_kind = kind.unwrap_or_else(|| "public".to_string());
|
||||||
.query("CREATE room SET name = $name, created = time::now()")
|
if !matches!(room_kind.as_str(), "public" | "private") {
|
||||||
.bind(("name", name))
|
return Err(AppError::Auth("room kind must be public or private".into()).to_string());
|
||||||
.await
|
|
||||||
.map_err(into_err)?
|
|
||||||
.take(0)
|
|
||||||
.map_err(into_err)?;
|
|
||||||
|
|
||||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("room after create".into())))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch all rooms.
|
let mut result: Vec<Room> = state
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_rooms(state: State<'_, AppState>) -> Result<Vec<Room>, String> {
|
|
||||||
let result: Vec<Room> = state
|
|
||||||
.db
|
.db
|
||||||
.query("SELECT * FROM room ORDER BY created DESC")
|
.query(
|
||||||
|
"CREATE room SET
|
||||||
|
name = $name,
|
||||||
|
kind = $kind,
|
||||||
|
created_by = $auth,
|
||||||
|
created = time::now(),
|
||||||
|
updated = time::now()",
|
||||||
|
)
|
||||||
|
.bind(("name", name.trim().to_string()))
|
||||||
|
.bind(("kind", room_kind))
|
||||||
.await
|
.await
|
||||||
.map_err(into_err)?
|
.map_err(into_err)?
|
||||||
.take(0)
|
.take(0)
|
||||||
.map_err(into_err)?;
|
.map_err(into_err)?;
|
||||||
|
|
||||||
Ok(result)
|
let room = result
|
||||||
|
.pop()
|
||||||
|
.ok_or_else(|| into_err(AppError::NotFound("room after create".into())))?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"CREATE room_member SET
|
||||||
|
room = $room,
|
||||||
|
user = $auth,
|
||||||
|
role = 'owner',
|
||||||
|
joined = time::now(),
|
||||||
|
last_read_at = time::now(),
|
||||||
|
muted = false",
|
||||||
|
)
|
||||||
|
.bind(("room", room.id.clone()))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
Ok(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch public rooms and rooms the current user belongs to.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_rooms(state: State<'_, AppState>) -> Result<Vec<Room>, String> {
|
||||||
|
let mut result: Vec<Room> = state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"SELECT * FROM room
|
||||||
|
WHERE kind = 'public' OR id IN (SELECT VALUE room FROM room_member WHERE user = $auth)
|
||||||
|
ORDER BY updated DESC, created DESC",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
hydrate_direct_rooms(&state, &mut result).await?;
|
||||||
|
Ok(dedupe_direct_rooms(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a user to a room. Room owners can invite others.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn invite_to_room(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
room_id: String,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"CREATE room_member SET
|
||||||
|
room = type::record('room', $room_id),
|
||||||
|
user = type::record('user', $user_id),
|
||||||
|
role = 'member',
|
||||||
|
joined = time::now(),
|
||||||
|
muted = false",
|
||||||
|
)
|
||||||
|
.bind(("room_id", room_id))
|
||||||
|
.bind(("user_id", user_id))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return an existing direct room for two users or create it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_or_create_direct_room(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<Room, String> {
|
||||||
|
let me = current_user(&state).await?;
|
||||||
|
let target_user_id = user_id_key(&user_id);
|
||||||
|
let current_user_key = record_key_string(&me.id);
|
||||||
|
if user_record_key(¤t_user_key) == user_record_key(&target_user_id) {
|
||||||
|
return Err(
|
||||||
|
AppError::Auth("cannot start a direct message with yourself".into()).to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let direct_key = direct_room_key(&me.id, &target_user_id);
|
||||||
|
|
||||||
|
let mut existing: Vec<Room> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT * FROM room WHERE kind = 'direct' AND direct_key = $direct_key LIMIT 1")
|
||||||
|
.bind(("direct_key", direct_key.clone()))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
if let Some(mut room) = existing.pop() {
|
||||||
|
hydrate_direct_rooms(&state, std::slice::from_mut(&mut room)).await?;
|
||||||
|
return Ok(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut existing_by_members: Vec<Room> = state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"SELECT * FROM room
|
||||||
|
WHERE kind = 'direct'
|
||||||
|
AND id IN (SELECT VALUE room FROM room_member WHERE user = $auth)
|
||||||
|
AND id IN (SELECT VALUE room FROM room_member WHERE user = type::record('user', $user_id))
|
||||||
|
ORDER BY updated DESC, created DESC
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(("user_id", target_user_id.clone()))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
if let Some(mut room) = existing_by_members.pop() {
|
||||||
|
hydrate_direct_rooms(&state, std::slice::from_mut(&mut room)).await?;
|
||||||
|
return Ok(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut created: Vec<Room> = state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"CREATE room SET
|
||||||
|
name = NONE,
|
||||||
|
kind = 'direct',
|
||||||
|
direct_key = $direct_key,
|
||||||
|
created_by = $auth,
|
||||||
|
created = time::now(),
|
||||||
|
updated = time::now()",
|
||||||
|
)
|
||||||
|
.bind(("direct_key", direct_key))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
let room = created
|
||||||
|
.pop()
|
||||||
|
.ok_or_else(|| into_err(AppError::NotFound("direct room after create".into())))?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"CREATE room_member SET room = $room, user = $auth, role = 'owner', joined = time::now(), last_read_at = time::now(), muted = false;
|
||||||
|
CREATE room_member SET room = $room, user = type::record('user', $user_id), role = 'member', joined = time::now(), muted = false;",
|
||||||
|
)
|
||||||
|
.bind(("room", room.id.clone()))
|
||||||
|
.bind(("user_id", target_user_id))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
let mut room = room;
|
||||||
|
hydrate_direct_rooms(&state, std::slice::from_mut(&mut room)).await?;
|
||||||
|
Ok(room)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a message to a room.
|
/// Send a message to a room.
|
||||||
@@ -53,54 +381,101 @@ pub async fn send_message(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
room_id: String,
|
room_id: String,
|
||||||
body: String,
|
body: String,
|
||||||
|
reply_to: Option<String>,
|
||||||
) -> Result<Message, String> {
|
) -> Result<Message, String> {
|
||||||
let mut result: Vec<Message> = state
|
validate_message_body(&body)?;
|
||||||
.db
|
|
||||||
.query(
|
let query = if reply_to.is_some() {
|
||||||
"CREATE message SET
|
"CREATE message SET
|
||||||
room = type::record('room', $room_id),
|
room = type::record('room', $room_id),
|
||||||
author = $auth,
|
author = $auth,
|
||||||
author_username = $auth.username,
|
author_username = $auth.username,
|
||||||
body = $body,
|
body = $body,
|
||||||
created = time::now()",
|
reply_to = type::record('message', $reply_to),
|
||||||
)
|
deleted = false,
|
||||||
|
created = time::now();
|
||||||
|
UPDATE type::record('room', $room_id) SET updated = time::now();"
|
||||||
|
} else {
|
||||||
|
"CREATE message SET
|
||||||
|
room = type::record('room', $room_id),
|
||||||
|
author = $auth,
|
||||||
|
author_username = $auth.username,
|
||||||
|
body = $body,
|
||||||
|
deleted = false,
|
||||||
|
created = time::now();
|
||||||
|
UPDATE type::record('room', $room_id) SET updated = time::now();"
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut response = state
|
||||||
|
.db
|
||||||
|
.query(query)
|
||||||
.bind(("room_id", room_id))
|
.bind(("room_id", room_id))
|
||||||
.bind(("body", body))
|
.bind(("body", body.trim().to_string()));
|
||||||
|
|
||||||
|
if let Some(reply_to) = reply_to {
|
||||||
|
response = response.bind(("reply_to", reply_to));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result: Vec<Message> = response
|
||||||
.await
|
.await
|
||||||
.map_err(into_err)?
|
.map_err(into_err)?
|
||||||
.take(0)
|
.take(0)
|
||||||
.map_err(into_err)?;
|
.map_err(into_err)?;
|
||||||
|
|
||||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("message after create".into())))
|
result
|
||||||
|
.pop()
|
||||||
|
.ok_or_else(|| into_err(AppError::NotFound("message after create".into())))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch all messages in a room, oldest first.
|
/// Fetch a bounded page of messages in a room, oldest first.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_messages(
|
pub async fn get_messages(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
room_id: String,
|
room_id: String,
|
||||||
|
before: Option<String>,
|
||||||
|
limit: Option<i64>,
|
||||||
) -> Result<Vec<Message>, String> {
|
) -> Result<Vec<Message>, String> {
|
||||||
let result: Vec<Message> = state
|
let limit = limit.unwrap_or(DEFAULT_PAGE_SIZE).clamp(1, MAX_PAGE_SIZE);
|
||||||
|
let query = if before.is_some() {
|
||||||
|
"SELECT * FROM message
|
||||||
|
WHERE room = type::record('room', $room_id) AND created < <datetime>$before
|
||||||
|
ORDER BY created DESC
|
||||||
|
LIMIT $limit"
|
||||||
|
} else {
|
||||||
|
"SELECT * FROM message
|
||||||
|
WHERE room = type::record('room', $room_id)
|
||||||
|
ORDER BY created DESC
|
||||||
|
LIMIT $limit"
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut response = state
|
||||||
.db
|
.db
|
||||||
.query("SELECT * FROM message WHERE room = type::record('room', $room_id) ORDER BY created ASC")
|
.query(query)
|
||||||
.bind(("room_id", room_id))
|
.bind(("room_id", room_id))
|
||||||
|
.bind(("limit", limit));
|
||||||
|
|
||||||
|
if let Some(before) = before {
|
||||||
|
response = response.bind(("before", before));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result: Vec<Message> = response
|
||||||
.await
|
.await
|
||||||
.map_err(into_err)?
|
.map_err(into_err)?
|
||||||
.take(0)
|
.take(0)
|
||||||
.map_err(into_err)?;
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.reverse();
|
||||||
|
let user = current_user(&state).await?;
|
||||||
|
hydrate_reactions(&state, &user, &mut result).await?;
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a message by its ID string (e.g. "message:abc123").
|
/// Soft-delete a message by its ID string.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_message(
|
pub async fn delete_message(state: State<'_, AppState>, message_id: String) -> Result<(), String> {
|
||||||
state: State<'_, AppState>,
|
|
||||||
message_id: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.query("DELETE type::record($id) WHERE author = $auth")
|
.query("UPDATE type::record($id) SET deleted = true, body = '', updated = time::now() WHERE author = $auth")
|
||||||
.bind(("id", message_id))
|
.bind(("id", message_id))
|
||||||
.await
|
.await
|
||||||
.map_err(into_err)?;
|
.map_err(into_err)?;
|
||||||
@@ -108,6 +483,85 @@ pub async fn delete_message(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Edit the current user's message.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn edit_message(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
message_id: String,
|
||||||
|
body: String,
|
||||||
|
) -> Result<Message, String> {
|
||||||
|
validate_message_body(&body)?;
|
||||||
|
let mut result: Vec<Message> = state
|
||||||
|
.db
|
||||||
|
.query("UPDATE type::record($id) SET body = $body, updated = time::now() WHERE author = $auth RETURN AFTER")
|
||||||
|
.bind(("id", message_id))
|
||||||
|
.bind(("body", body.trim().to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result
|
||||||
|
.pop()
|
||||||
|
.ok_or_else(|| into_err(AppError::NotFound("message".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle one emoji reaction for the current user.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn toggle_reaction(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
message_id: String,
|
||||||
|
emoji: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let emoji = emoji.trim();
|
||||||
|
if emoji.is_empty() || emoji.chars().count() > 16 {
|
||||||
|
return Err(AppError::Auth("invalid reaction".into()).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing: Vec<MessageReaction> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT * FROM message_reaction WHERE message = type::record($message_id) AND user = $auth AND emoji = $emoji")
|
||||||
|
.bind(("message_id", message_id.clone()))
|
||||||
|
.bind(("emoji", emoji.to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
if existing.is_empty() {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.query("CREATE message_reaction SET message = type::record($message_id), user = $auth, emoji = $emoji, created = time::now()")
|
||||||
|
.bind(("message_id", message_id))
|
||||||
|
.bind(("emoji", emoji.to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?;
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.query("DELETE message_reaction WHERE message = type::record($message_id) AND user = $auth AND emoji = $emoji")
|
||||||
|
.bind(("message_id", message_id))
|
||||||
|
.bind(("emoji", emoji.to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the room read for the current user.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn mark_room_read(state: State<'_, AppState>, room_id: String) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.query("UPDATE room_member SET last_read_at = time::now() WHERE room = type::record('room', $room_id) AND user = $auth")
|
||||||
|
.bind(("room_id", room_id))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Start a LIVE query for new messages in a room.
|
/// Start a LIVE query for new messages in a room.
|
||||||
/// Spawns a background tokio task that emits "chat:message" Tauri events.
|
/// Spawns a background tokio task that emits "chat:message" Tauri events.
|
||||||
///
|
///
|
||||||
@@ -133,10 +587,13 @@ pub async fn subscribe_room(
|
|||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
while let Some(Ok(notification)) = stream.next().await {
|
while let Some(Ok(notification)) = stream.next().await {
|
||||||
let _ = app_handle.emit("chat:message", &LiveMessageEvent {
|
let _ = app_handle.emit(
|
||||||
|
"chat:message",
|
||||||
|
&LiveMessageEvent {
|
||||||
action: format!("{:?}", notification.action),
|
action: format!("{:?}", notification.action),
|
||||||
data: ¬ification.data,
|
data: ¬ification.data,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,10 +605,7 @@ pub async fn subscribe_room(
|
|||||||
/// Stop a LIVE query subscription.
|
/// Stop a LIVE query subscription.
|
||||||
/// Aborts the background task — dropping the stream closes the LIVE query.
|
/// Aborts the background task — dropping the stream closes the LIVE query.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn unsubscribe_room(
|
pub async fn unsubscribe_room(state: State<'_, AppState>, sub_id: String) -> Result<(), String> {
|
||||||
state: State<'_, AppState>,
|
|
||||||
sub_id: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let uuid = sub_id
|
let uuid = sub_id
|
||||||
.parse::<Uuid>()
|
.parse::<Uuid>()
|
||||||
.map_err(|e| into_err(AppError::Subscription(e.to_string())))?;
|
.map_err(|e| into_err(AppError::Subscription(e.to_string())))?;
|
||||||
|
|||||||
@@ -7,6 +7,58 @@ use crate::models::{Contact, User};
|
|||||||
|
|
||||||
const SESSION_STORE: &str = "session.json";
|
const SESSION_STORE: &str = "session.json";
|
||||||
const TOKEN_KEY: &str = "token";
|
const TOKEN_KEY: &str = "token";
|
||||||
|
const MIN_PASSWORD_LEN: usize = 8;
|
||||||
|
const MAX_USERNAME_LEN: usize = 32;
|
||||||
|
const MAX_EMAIL_LEN: usize = 254;
|
||||||
|
|
||||||
|
fn validate_email(email: &str) -> Result<(), String> {
|
||||||
|
let email = email.trim();
|
||||||
|
if email.is_empty() || email.len() > MAX_EMAIL_LEN || !email.contains('@') {
|
||||||
|
return Err(AppError::Auth("enter a valid email address".into()).to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_password(password: &str) -> Result<(), String> {
|
||||||
|
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||||
|
return Err(AppError::Auth(format!(
|
||||||
|
"password must be at least {MIN_PASSWORD_LEN} characters"
|
||||||
|
))
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_username(username: &str) -> Result<(), String> {
|
||||||
|
let username = username.trim();
|
||||||
|
if username.is_empty() || username.chars().count() > MAX_USERNAME_LEN {
|
||||||
|
return Err(
|
||||||
|
AppError::Auth(format!("username must be 1-{MAX_USERNAME_LEN} characters")).to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !username
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
AppError::Auth("username can use letters, numbers, _, -, and .".into()).to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_avatar(avatar: &Option<String>) -> Result<(), String> {
|
||||||
|
if let Some(avatar) = avatar {
|
||||||
|
let avatar = avatar.trim();
|
||||||
|
if !avatar.is_empty() && !(avatar.starts_with("https://") || avatar.starts_with("http://"))
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
AppError::Auth("avatar must be a valid http or https URL".into()).to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new user account via SurrealDB Record Auth SIGNUP.
|
/// Create a new user account via SurrealDB Record Auth SIGNUP.
|
||||||
/// Returns the created User record. Persists the JWT token to disk.
|
/// Returns the created User record. Persists the JWT token to disk.
|
||||||
@@ -18,13 +70,17 @@ pub async fn signup(
|
|||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<User, String> {
|
) -> Result<User, String> {
|
||||||
|
validate_email(&email)?;
|
||||||
|
validate_username(&username)?;
|
||||||
|
validate_password(&password)?;
|
||||||
|
|
||||||
let credentials = surrealdb::opt::auth::Record {
|
let credentials = surrealdb::opt::auth::Record {
|
||||||
access: SURREAL_ACCESS.to_string(),
|
access: SURREAL_ACCESS.to_string(),
|
||||||
namespace: SURREAL_NS.to_string(),
|
namespace: SURREAL_NS.to_string(),
|
||||||
database: SURREAL_DB.to_string(),
|
database: SURREAL_DB.to_string(),
|
||||||
params: serde_json::json!({
|
params: serde_json::json!({
|
||||||
"email": email,
|
"email": email.trim(),
|
||||||
"username": username,
|
"username": username.trim(),
|
||||||
"password": password,
|
"password": password,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -41,7 +97,9 @@ pub async fn signup(
|
|||||||
.take(0)
|
.take(0)
|
||||||
.map_err(into_err)?;
|
.map_err(into_err)?;
|
||||||
|
|
||||||
result.pop().ok_or_else(|| into_err(AppError::Auth("signup succeeded but $auth not set".into())))
|
result
|
||||||
|
.pop()
|
||||||
|
.ok_or_else(|| into_err(AppError::Auth("signup succeeded but $auth not set".into())))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate an existing user via SurrealDB Record Auth SIGNIN.
|
/// Authenticate an existing user via SurrealDB Record Auth SIGNIN.
|
||||||
@@ -53,16 +111,25 @@ pub async fn signin(
|
|||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
validate_email(&email)?;
|
||||||
|
validate_password(&password)?;
|
||||||
|
|
||||||
let credentials = surrealdb::opt::auth::Record {
|
let credentials = surrealdb::opt::auth::Record {
|
||||||
access: SURREAL_ACCESS.to_string(),
|
access: SURREAL_ACCESS.to_string(),
|
||||||
namespace: SURREAL_NS.to_string(),
|
namespace: SURREAL_NS.to_string(),
|
||||||
database: SURREAL_DB.to_string(),
|
database: SURREAL_DB.to_string(),
|
||||||
params: serde_json::json!({
|
params: serde_json::json!({
|
||||||
"email": email,
|
"email": email.trim(),
|
||||||
"password": password,
|
"password": password,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
let token_str = state.db.signin(credentials).await.map_err(into_err)?.access.into_insecure_token();
|
let token_str = state
|
||||||
|
.db
|
||||||
|
.signin(credentials)
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.access
|
||||||
|
.into_insecure_token();
|
||||||
*state.token.lock().unwrap() = Some(token_str.clone());
|
*state.token.lock().unwrap() = Some(token_str.clone());
|
||||||
save_token(&app_handle, &token_str)?;
|
save_token(&app_handle, &token_str)?;
|
||||||
Ok(token_str)
|
Ok(token_str)
|
||||||
@@ -70,10 +137,7 @@ pub async fn signin(
|
|||||||
|
|
||||||
/// Clear the current session. Invalidates the token in state and removes it from disk.
|
/// Clear the current session. Invalidates the token in state and removes it from disk.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn signout(
|
pub async fn signout(state: State<'_, AppState>, app_handle: AppHandle) -> Result<(), String> {
|
||||||
state: State<'_, AppState>,
|
|
||||||
app_handle: AppHandle,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.db.invalidate().await.map_err(into_err)?;
|
state.db.invalidate().await.map_err(into_err)?;
|
||||||
*state.token.lock().unwrap() = None;
|
*state.token.lock().unwrap() = None;
|
||||||
clear_token(&app_handle)?;
|
clear_token(&app_handle)?;
|
||||||
@@ -89,11 +153,14 @@ pub async fn restore_session(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
) -> Result<User, String> {
|
) -> Result<User, String> {
|
||||||
let token_str = load_token(&app_handle)?.ok_or_else(|| {
|
let token_str = load_token(&app_handle)?
|
||||||
AppError::Auth("no saved session".into()).to_string()
|
.ok_or_else(|| AppError::Auth("no saved session".into()).to_string())?;
|
||||||
})?;
|
|
||||||
|
|
||||||
match state.db.authenticate(surrealdb::opt::auth::Token::from(token_str.clone())).await {
|
match state
|
||||||
|
.db
|
||||||
|
.authenticate(surrealdb::opt::auth::Token::from(token_str.clone()))
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
*state.token.lock().unwrap() = Some(token_str);
|
*state.token.lock().unwrap() = Some(token_str);
|
||||||
|
|
||||||
@@ -105,7 +172,9 @@ pub async fn restore_session(
|
|||||||
.take(0)
|
.take(0)
|
||||||
.map_err(into_err)?;
|
.map_err(into_err)?;
|
||||||
|
|
||||||
result.pop().ok_or_else(|| into_err(AppError::Auth("session restored but $auth not set".into())))
|
result.pop().ok_or_else(|| {
|
||||||
|
into_err(AppError::Auth("session restored but $auth not set".into()))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = clear_token(&app_handle);
|
let _ = clear_token(&app_handle);
|
||||||
@@ -126,7 +195,9 @@ pub async fn get_me(state: State<'_, AppState>) -> Result<User, String> {
|
|||||||
.take(0)
|
.take(0)
|
||||||
.map_err(into_err)?;
|
.map_err(into_err)?;
|
||||||
|
|
||||||
result.pop().ok_or_else(|| into_err(AppError::Auth("not authenticated".into())))
|
result
|
||||||
|
.pop()
|
||||||
|
.ok_or_else(|| into_err(AppError::Auth("not authenticated".into())))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update mutable profile fields. Only provided fields are changed.
|
/// Update mutable profile fields. Only provided fields are changed.
|
||||||
@@ -136,6 +207,11 @@ pub async fn update_profile(
|
|||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
avatar: Option<String>,
|
avatar: Option<String>,
|
||||||
) -> Result<User, String> {
|
) -> Result<User, String> {
|
||||||
|
if let Some(username) = &username {
|
||||||
|
validate_username(username)?;
|
||||||
|
}
|
||||||
|
validate_avatar(&avatar)?;
|
||||||
|
|
||||||
let mut result: Vec<User> = state
|
let mut result: Vec<User> = state
|
||||||
.db
|
.db
|
||||||
.query(
|
.query(
|
||||||
@@ -144,14 +220,46 @@ pub async fn update_profile(
|
|||||||
avatar = $avatar ?? avatar
|
avatar = $avatar ?? avatar
|
||||||
RETURN AFTER",
|
RETURN AFTER",
|
||||||
)
|
)
|
||||||
.bind(("username", username))
|
.bind(("username", username.map(|s| s.trim().to_string())))
|
||||||
.bind(("avatar", avatar))
|
.bind((
|
||||||
|
"avatar",
|
||||||
|
avatar
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty()),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.map_err(into_err)?
|
.map_err(into_err)?
|
||||||
.take(0)
|
.take(0)
|
||||||
.map_err(into_err)?;
|
.map_err(into_err)?;
|
||||||
|
|
||||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("user".into())))
|
result
|
||||||
|
.pop()
|
||||||
|
.ok_or_else(|| into_err(AppError::NotFound("user".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search users by username. Returns safe profile fields only.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn search_users(state: State<'_, AppState>, query: String) -> Result<Vec<User>, String> {
|
||||||
|
let query = query.trim();
|
||||||
|
if query.chars().count() < 2 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Vec<User> = state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"SELECT id, username, email, avatar, created FROM user
|
||||||
|
WHERE id != $auth AND string::lowercase(username) CONTAINS string::lowercase($query)
|
||||||
|
ORDER BY username
|
||||||
|
LIMIT 10",
|
||||||
|
)
|
||||||
|
.bind(("query", query.to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the contacts list for the current user.
|
/// Return the contacts list for the current user.
|
||||||
@@ -159,7 +267,11 @@ pub async fn update_profile(
|
|||||||
pub async fn get_contacts(state: State<'_, AppState>) -> Result<Vec<User>, String> {
|
pub async fn get_contacts(state: State<'_, AppState>) -> Result<Vec<User>, String> {
|
||||||
let result: Vec<User> = state
|
let result: Vec<User> = state
|
||||||
.db
|
.db
|
||||||
.query("SELECT target.* FROM contact WHERE owner = $auth")
|
.query(
|
||||||
|
"SELECT * FROM user
|
||||||
|
WHERE id IN (SELECT VALUE target FROM contact WHERE owner = $auth)
|
||||||
|
ORDER BY username",
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(into_err)?
|
.map_err(into_err)?
|
||||||
.take(0)
|
.take(0)
|
||||||
@@ -170,10 +282,7 @@ pub async fn get_contacts(state: State<'_, AppState>) -> Result<Vec<User>, Strin
|
|||||||
|
|
||||||
/// Add a user to the current user's contact list.
|
/// Add a user to the current user's contact list.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_contact(
|
pub async fn add_contact(state: State<'_, AppState>, user_id: String) -> Result<Contact, String> {
|
||||||
state: State<'_, AppState>,
|
|
||||||
user_id: String,
|
|
||||||
) -> Result<Contact, String> {
|
|
||||||
let mut result: Vec<Contact> = state
|
let mut result: Vec<Contact> = state
|
||||||
.db
|
.db
|
||||||
.query("CREATE contact SET owner = $auth, target = type::record('user', $uid)")
|
.query("CREATE contact SET owner = $auth, target = type::record('user', $uid)")
|
||||||
@@ -183,7 +292,9 @@ pub async fn add_contact(
|
|||||||
.take(0)
|
.take(0)
|
||||||
.map_err(into_err)?;
|
.map_err(into_err)?;
|
||||||
|
|
||||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("contact after create".into())))
|
result
|
||||||
|
.pop()
|
||||||
|
.ok_or_else(|| into_err(AppError::NotFound("contact after create".into())))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private helpers ───────────────────────────────────────────────────────────
|
// ── Private helpers ───────────────────────────────────────────────────────────
|
||||||
@@ -196,7 +307,9 @@ fn save_token(app: &AppHandle, token: &str) -> Result<(), String> {
|
|||||||
|
|
||||||
fn load_token(app: &AppHandle) -> Result<Option<String>, String> {
|
fn load_token(app: &AppHandle) -> Result<Option<String>, String> {
|
||||||
let store = app.store(SESSION_STORE).map_err(|e| e.to_string())?;
|
let store = app.store(SESSION_STORE).map_err(|e| e.to_string())?;
|
||||||
Ok(store.get(TOKEN_KEY).and_then(|v| v.as_str().map(String::from)))
|
Ok(store
|
||||||
|
.get(TOKEN_KEY)
|
||||||
|
.and_then(|v| v.as_str().map(String::from)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_token(app: &AppHandle) -> Result<(), String> {
|
fn clear_token(app: &AppHandle) -> Result<(), String> {
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ pub fn run() {
|
|||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
tauri::async_runtime::block_on(async move {
|
tauri::async_runtime::block_on(async move {
|
||||||
let surreal = init_db(SURREAL_URL.as_str(), SURREAL_NS.as_str(), SURREAL_DB.as_str())
|
let surreal = init_db(
|
||||||
|
SURREAL_URL.as_str(),
|
||||||
|
SURREAL_NS.as_str(),
|
||||||
|
SURREAL_DB.as_str(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to connect to SurrealDB");
|
.expect("Failed to connect to SurrealDB");
|
||||||
|
|
||||||
@@ -39,13 +43,19 @@ pub fn run() {
|
|||||||
commands::user::get_me,
|
commands::user::get_me,
|
||||||
commands::user::restore_session,
|
commands::user::restore_session,
|
||||||
commands::user::update_profile,
|
commands::user::update_profile,
|
||||||
|
commands::user::search_users,
|
||||||
commands::user::get_contacts,
|
commands::user::get_contacts,
|
||||||
commands::user::add_contact,
|
commands::user::add_contact,
|
||||||
commands::chat::create_room,
|
commands::chat::create_room,
|
||||||
commands::chat::get_rooms,
|
commands::chat::get_rooms,
|
||||||
|
commands::chat::invite_to_room,
|
||||||
|
commands::chat::get_or_create_direct_room,
|
||||||
commands::chat::send_message,
|
commands::chat::send_message,
|
||||||
commands::chat::get_messages,
|
commands::chat::get_messages,
|
||||||
commands::chat::delete_message,
|
commands::chat::delete_message,
|
||||||
|
commands::chat::edit_message,
|
||||||
|
commands::chat::toggle_reaction,
|
||||||
|
commands::chat::mark_room_read,
|
||||||
commands::chat::subscribe_room,
|
commands::chat::subscribe_room,
|
||||||
commands::chat::unsubscribe_room,
|
commands::chat::unsubscribe_room,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use surrealdb_types::SurrealValue;
|
|||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: RecordId,
|
pub id: RecordId,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: Option<String>,
|
||||||
pub avatar: Option<String>,
|
pub avatar: Option<String>,
|
||||||
pub created: Datetime,
|
pub created: Datetime,
|
||||||
}
|
}
|
||||||
@@ -14,8 +14,27 @@ pub struct User {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||||
pub struct Room {
|
pub struct Room {
|
||||||
pub id: RecordId,
|
pub id: RecordId,
|
||||||
pub name: String,
|
pub name: Option<String>,
|
||||||
|
pub kind: String,
|
||||||
|
pub created_by: Option<RecordId>,
|
||||||
|
pub direct_key: Option<String>,
|
||||||
pub created: Datetime,
|
pub created: Datetime,
|
||||||
|
pub updated: Option<Datetime>,
|
||||||
|
pub last_message: Option<Message>,
|
||||||
|
pub unread_count: Option<i64>,
|
||||||
|
pub other_user: Option<User>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct RoomMember {
|
||||||
|
pub id: RecordId,
|
||||||
|
pub room: RecordId,
|
||||||
|
pub user: RecordId,
|
||||||
|
pub role: String,
|
||||||
|
pub joined: Datetime,
|
||||||
|
pub last_read_at: Option<Datetime>,
|
||||||
|
pub muted: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||||
@@ -26,6 +45,26 @@ pub struct Message {
|
|||||||
pub author_username: Option<String>,
|
pub author_username: Option<String>,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub created: Datetime,
|
pub created: Datetime,
|
||||||
|
pub updated: Option<Datetime>,
|
||||||
|
pub deleted: Option<bool>,
|
||||||
|
pub reply_to: Option<RecordId>,
|
||||||
|
pub reactions: Option<Vec<MessageReactionSummary>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||||
|
pub struct MessageReaction {
|
||||||
|
pub id: RecordId,
|
||||||
|
pub message: RecordId,
|
||||||
|
pub user: RecordId,
|
||||||
|
pub emoji: String,
|
||||||
|
pub created: Datetime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||||
|
pub struct MessageReactionSummary {
|
||||||
|
pub emoji: String,
|
||||||
|
pub count: i64,
|
||||||
|
pub reacted_by_me: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||||
@@ -45,7 +84,10 @@ mod tests {
|
|||||||
fn _assert_serialize<T: Serialize + for<'de> Deserialize<'de>>() {}
|
fn _assert_serialize<T: Serialize + for<'de> Deserialize<'de>>() {}
|
||||||
_assert_serialize::<User>();
|
_assert_serialize::<User>();
|
||||||
_assert_serialize::<Room>();
|
_assert_serialize::<Room>();
|
||||||
|
_assert_serialize::<RoomMember>();
|
||||||
_assert_serialize::<Message>();
|
_assert_serialize::<Message>();
|
||||||
|
_assert_serialize::<MessageReaction>();
|
||||||
|
_assert_serialize::<MessageReactionSummary>();
|
||||||
_assert_serialize::<Contact>();
|
_assert_serialize::<Contact>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="auth-wrap">
|
<div class="auth-wrap">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<h1 class="auth-brand">OXYDE</h1>
|
<h1 class="auth-brand">OXYDE</h1>
|
||||||
<p class="auth-tagline">encrypted · realtime · distributed</p>
|
<p class="auth-tagline">realtime · native · focused</p>
|
||||||
|
|
||||||
{#if err}
|
{#if err}
|
||||||
<div class="err-banner">{err}</div>
|
<div class="err-banner">{err}</div>
|
||||||
|
|||||||
@@ -8,9 +8,15 @@
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
user: User | null;
|
user: User | null;
|
||||||
err: string;
|
err: string;
|
||||||
|
hasOlderMessages: boolean;
|
||||||
|
isLoadingOlder: boolean;
|
||||||
fMsg: string;
|
fMsg: string;
|
||||||
|
replyTo: Message | null;
|
||||||
|
onLoadOlderMessages: () => void;
|
||||||
onSendMessage: () => void;
|
onSendMessage: () => void;
|
||||||
onDeleteMessage: (msgId: string) => void;
|
onDeleteMessage: (msgId: string) => void;
|
||||||
|
onEditMessage: (msgId: string, body: string) => void;
|
||||||
|
onToggleReaction: (msgId: string, emoji: string) => void;
|
||||||
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
|
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,14 +25,22 @@
|
|||||||
messages,
|
messages,
|
||||||
user,
|
user,
|
||||||
err,
|
err,
|
||||||
|
hasOlderMessages,
|
||||||
|
isLoadingOlder,
|
||||||
fMsg = $bindable(),
|
fMsg = $bindable(),
|
||||||
|
replyTo = $bindable(),
|
||||||
|
onLoadOlderMessages,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onDeleteMessage,
|
onDeleteMessage,
|
||||||
|
onEditMessage,
|
||||||
|
onToggleReaction,
|
||||||
onShowMenu,
|
onShowMenu,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let msgEl: HTMLElement;
|
let msgEl: HTMLElement;
|
||||||
let inputEl: HTMLTextAreaElement;
|
let inputEl: HTMLTextAreaElement;
|
||||||
|
let editingId = $state<string | null>(null);
|
||||||
|
let editBody = $state('');
|
||||||
|
|
||||||
function scrollBottom() {
|
function scrollBottom() {
|
||||||
tick().then(() => { if (msgEl) msgEl.scrollTop = msgEl.scrollHeight; });
|
tick().then(() => { if (msgEl) msgEl.scrollTop = msgEl.scrollHeight; });
|
||||||
@@ -42,11 +56,34 @@
|
|||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSendMessage(); }
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSendMessage(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roomLabel(room: Room | null): string {
|
||||||
|
if (!room) return 'select a room';
|
||||||
|
if (room.kind === 'direct') return room.other_user?.username ?? room.name ?? 'direct message';
|
||||||
|
return room.name ?? 'untitled';
|
||||||
|
}
|
||||||
|
|
||||||
function isGrouped(i: number): boolean {
|
function isGrouped(i: number): boolean {
|
||||||
if (i === 0) return false;
|
if (i === 0) return false;
|
||||||
|
if (messages[i].deleted || messages[i - 1].deleted) return false;
|
||||||
return full(messages[i].author) === full(messages[i - 1].author);
|
return full(messages[i].author) === full(messages[i - 1].author);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function beginEdit(msg: Message) {
|
||||||
|
editingId = full(msg.id);
|
||||||
|
editBody = msg.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitEdit(msg: Message) {
|
||||||
|
if (!editBody.trim()) return;
|
||||||
|
onEditMessage(full(msg.id), editBody.trim());
|
||||||
|
editingId = null;
|
||||||
|
editBody = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickReact(msg: Message) {
|
||||||
|
onToggleReaction(full(msg.id), '+1');
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll to bottom when messages change
|
// Scroll to bottom when messages change
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
messages.length; // track length
|
messages.length; // track length
|
||||||
@@ -63,8 +100,8 @@
|
|||||||
|
|
||||||
<!-- Channel header -->
|
<!-- Channel header -->
|
||||||
<header class="channel-header">
|
<header class="channel-header">
|
||||||
<span class="ch-hash">#</span>
|
<span class="ch-hash">{activeRoom?.kind === 'direct' ? '@' : '#'}</span>
|
||||||
<span class="ch-name">{activeRoom?.name ?? 'select a room'}</span>
|
<span class="ch-name">{roomLabel(activeRoom)}</span>
|
||||||
{#if err}<span class="header-err">{err}</span>{/if}
|
{#if err}<span class="header-err">{err}</span>{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -81,15 +118,24 @@
|
|||||||
<p>no messages yet — say hello</p>
|
<p>no messages yet — say hello</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if hasOlderMessages}
|
||||||
|
<button class="load-older" onclick={onLoadOlderMessages} disabled={isLoadingOlder}>
|
||||||
|
{isLoadingOlder ? 'loading...' : 'load older messages'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#each messages as msg, i (full(msg.id))}
|
{#each messages as msg, i (full(msg.id))}
|
||||||
<div
|
<div
|
||||||
class="msg"
|
class="msg"
|
||||||
class:grouped={isGrouped(i)}
|
class:grouped={isGrouped(i)}
|
||||||
|
role="listitem"
|
||||||
oncontextmenu={(e) => {
|
oncontextmenu={(e) => {
|
||||||
const items: ContextMenuItem[] = [
|
const items: ContextMenuItem[] = [
|
||||||
{ label: 'Copy message', action: () => navigator.clipboard.writeText(msg.body) },
|
{ label: 'Copy message', action: () => navigator.clipboard.writeText(msg.body) },
|
||||||
|
{ label: 'Reply', action: () => replyTo = msg },
|
||||||
|
{ label: 'React +1', action: () => onToggleReaction(full(msg.id), '+1') },
|
||||||
];
|
];
|
||||||
if (user && full(msg.author) === full(user.id)) {
|
if (user && full(msg.author) === full(user.id) && !msg.deleted) {
|
||||||
|
items.push({ label: 'Edit message', action: () => beginEdit(msg) });
|
||||||
items.push({ label: 'Delete message', action: () => onDeleteMessage(full(msg.id)) });
|
items.push({ label: 'Delete message', action: () => onDeleteMessage(full(msg.id)) });
|
||||||
}
|
}
|
||||||
onShowMenu(e, items);
|
onShowMenu(e, items);
|
||||||
@@ -99,26 +145,70 @@
|
|||||||
<div class="msg-header">
|
<div class="msg-header">
|
||||||
<span
|
<span
|
||||||
class="msg-author"
|
class="msg-author"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
oncontextmenu={(e) => { e.stopPropagation(); onShowMenu(e, [
|
oncontextmenu={(e) => { e.stopPropagation(); onShowMenu(e, [
|
||||||
{ label: 'Copy username', action: () => navigator.clipboard.writeText(msg.author_username ?? sid(msg.author)) },
|
{ label: 'Copy username', action: () => navigator.clipboard.writeText(msg.author_username ?? sid(msg.author)) },
|
||||||
{ label: 'Copy user ID', action: () => navigator.clipboard.writeText(sid(msg.author)) },
|
{ label: 'Copy user ID', action: () => navigator.clipboard.writeText(sid(msg.author)) },
|
||||||
]); }}
|
]); }}
|
||||||
>{msg.author_username ?? sid(msg.author)}</span>
|
>{msg.author_username ?? sid(msg.author)}</span>
|
||||||
<span class="msg-time">{fmt(msg.created)}</span>
|
<span class="msg-time">{fmt(msg.created)}</span>
|
||||||
|
{#if msg.updated}<span class="msg-time">edited</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if msg.reply_to}
|
||||||
|
<div class="reply-chip">replying to {sid(msg.reply_to)}</div>
|
||||||
|
{/if}
|
||||||
|
{#if !msg.deleted}
|
||||||
|
<div class="msg-actions" aria-label="message actions">
|
||||||
|
<button title="Reply" onclick={() => replyTo = msg}>reply</button>
|
||||||
|
<button title="React" onclick={() => quickReact(msg)}>+1</button>
|
||||||
|
{#if user && full(msg.author) === full(user.id)}
|
||||||
|
<button title="Edit" onclick={() => beginEdit(msg)}>edit</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if msg.deleted}
|
||||||
|
<p class="msg-body deleted">message deleted</p>
|
||||||
|
{:else if editingId === full(msg.id)}
|
||||||
|
<div class="edit-row">
|
||||||
|
<textarea class="edit-input" bind:value={editBody} rows="2"></textarea>
|
||||||
|
<button class="mini-btn" onclick={() => submitEdit(msg)}>save</button>
|
||||||
|
<button class="mini-btn ghost" onclick={() => editingId = null}>cancel</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<p class="msg-body">{msg.body}</p>
|
<p class="msg-body">{msg.body}</p>
|
||||||
|
{/if}
|
||||||
|
{#if msg.reactions?.length}
|
||||||
|
<div class="reactions">
|
||||||
|
{#each msg.reactions as reaction}
|
||||||
|
<button
|
||||||
|
class="reaction"
|
||||||
|
class:mine={reaction.reacted_by_me}
|
||||||
|
onclick={() => onToggleReaction(full(msg.id), reaction.emoji)}
|
||||||
|
>
|
||||||
|
{reaction.emoji} {reaction.count}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input bar -->
|
<!-- Input bar -->
|
||||||
|
{#if replyTo}
|
||||||
|
<div class="reply-bar">
|
||||||
|
<span>replying to {replyTo.author_username ?? sid(replyTo.author)}</span>
|
||||||
|
<button class="mini-btn ghost" onclick={() => replyTo = null}>cancel</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="input-bar">
|
<div class="input-bar">
|
||||||
<textarea
|
<textarea
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
class="msg-input"
|
class="msg-input"
|
||||||
placeholder={activeRoom ? `message #${activeRoom.name}` : 'select a room first'}
|
placeholder={activeRoom ? `message ${activeRoom.kind === 'direct' ? '@' : '#'}${roomLabel(activeRoom)}` : 'select a room first'}
|
||||||
bind:value={fMsg}
|
bind:value={fMsg}
|
||||||
onkeydown={onKey}
|
onkeydown={onKey}
|
||||||
oninput={autoResize}
|
oninput={autoResize}
|
||||||
@@ -162,6 +252,14 @@
|
|||||||
.messages::-webkit-scrollbar { width: 4px; }
|
.messages::-webkit-scrollbar { width: 4px; }
|
||||||
.messages::-webkit-scrollbar-thumb { background: var(--surface-2); border-radius: 2px; }
|
.messages::-webkit-scrollbar-thumb { background: var(--surface-2); border-radius: 2px; }
|
||||||
.messages::-webkit-scrollbar-track { background: transparent; }
|
.messages::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.load-older {
|
||||||
|
align-self: center; margin-bottom: 12px; padding: 6px 10px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r); color: var(--text-2);
|
||||||
|
font-family: inherit; font-size: 11px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.load-older:hover { border-color: var(--accent); color: var(--text); }
|
||||||
|
.load-older:disabled { opacity: 0.5; cursor: wait; }
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
flex: 1; display: flex; flex-direction: column;
|
flex: 1; display: flex; flex-direction: column;
|
||||||
@@ -175,6 +273,8 @@
|
|||||||
.empty-state p { font-size: 11px; letter-spacing: 0.07em; }
|
.empty-state p { font-size: 11px; letter-spacing: 0.07em; }
|
||||||
|
|
||||||
.msg { padding: 1px 0; }
|
.msg { padding: 1px 0; }
|
||||||
|
.msg:hover .msg-actions,
|
||||||
|
.msg:focus-within .msg-actions { opacity: 1; pointer-events: auto; }
|
||||||
.msg.grouped { padding-top: 1px; }
|
.msg.grouped { padding-top: 1px; }
|
||||||
|
|
||||||
.msg-header {
|
.msg-header {
|
||||||
@@ -190,6 +290,51 @@
|
|||||||
animation: msgIn 0.14s ease;
|
animation: msgIn 0.14s ease;
|
||||||
}
|
}
|
||||||
.msg.grouped .msg-body { color: var(--text-2); }
|
.msg.grouped .msg-body { color: var(--text-2); }
|
||||||
|
.msg-body.deleted { color: var(--muted); font-style: italic; }
|
||||||
|
.msg-actions {
|
||||||
|
float: right; display: flex; gap: 4px; margin-left: 8px;
|
||||||
|
opacity: 0; pointer-events: none; transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
.msg-actions button {
|
||||||
|
padding: 2px 5px; background: var(--surface);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--r);
|
||||||
|
color: var(--muted); font-family: inherit; font-size: 9.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.msg-actions button:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.reply-chip {
|
||||||
|
display: inline-flex; margin: 2px 0 3px; padding: 3px 6px;
|
||||||
|
border-left: 2px solid var(--accent); background: var(--surface);
|
||||||
|
color: var(--muted); font-size: 10px;
|
||||||
|
}
|
||||||
|
.edit-row {
|
||||||
|
display: flex; align-items: flex-end; gap: 6px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.edit-input {
|
||||||
|
flex: 1; resize: vertical; min-height: 44px; max-height: 120px;
|
||||||
|
padding: 7px 9px; background: var(--surface);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--r);
|
||||||
|
color: var(--text); font-family: inherit; font-size: 12px;
|
||||||
|
}
|
||||||
|
.mini-btn {
|
||||||
|
padding: 6px 8px; background: var(--accent); border: none;
|
||||||
|
border-radius: var(--r); color: #fff; font-family: inherit;
|
||||||
|
font-size: 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mini-btn.ghost {
|
||||||
|
background: transparent; border: 1px solid var(--border); color: var(--muted);
|
||||||
|
}
|
||||||
|
.reactions {
|
||||||
|
display: flex; gap: 5px; margin-top: 4px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.reaction {
|
||||||
|
padding: 2px 6px; background: var(--surface);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--r);
|
||||||
|
color: var(--text-2); font-family: inherit; font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.reaction.mine { border-color: var(--accent); color: var(--accent); }
|
||||||
@keyframes msgIn {
|
@keyframes msgIn {
|
||||||
from { opacity: 0; transform: translateY(3px); }
|
from { opacity: 0; transform: translateY(3px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
@@ -201,6 +346,11 @@
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.reply-bar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 8px 24px; border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-2); font-size: 11px; background: var(--surface);
|
||||||
|
}
|
||||||
.msg-input {
|
.msg-input {
|
||||||
flex: 1; resize: none;
|
flex: 1; resize: none;
|
||||||
padding: 9px 13px;
|
padding: 9px 13px;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
bind:this={menuEl}
|
bind:this={menuEl}
|
||||||
style="left:{x}px; top:{y}px"
|
style="left:{x}px; top:{y}px"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||||
role="menu"
|
role="menu"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { User, Room, ContextMenuItem } from '$lib/types';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { full } from '$lib/helpers';
|
import type { User, Room, ContextMenuItem, UserSearchResult } from '$lib/types';
|
||||||
|
import { full, sid } from '$lib/helpers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -9,12 +10,17 @@
|
|||||||
activeRoom: Room | null;
|
activeRoom: Room | null;
|
||||||
showNewRoom: boolean;
|
showNewRoom: boolean;
|
||||||
fRoom: string;
|
fRoom: string;
|
||||||
|
fRoomKind: 'public' | 'private';
|
||||||
|
unreadCounts: Record<string, number>;
|
||||||
onSelectRoom: (room: Room) => void;
|
onSelectRoom: (room: Room) => void;
|
||||||
onCreateRoom: () => void;
|
onCreateRoom: () => void;
|
||||||
onSignout: () => void;
|
onSignout: () => void;
|
||||||
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
|
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
|
||||||
onUpdateProfile: (fields: { username?: string; avatar?: string }) => Promise<void>;
|
onUpdateProfile: (fields: { username?: string; avatar?: string }) => Promise<void>;
|
||||||
onAddContact: (userId: string) => Promise<void>;
|
onAddContact: (userId: string) => Promise<void>;
|
||||||
|
onSearchUsers: (query: string) => Promise<UserSearchResult[]>;
|
||||||
|
onStartDirectMessage: (userId: string) => Promise<void>;
|
||||||
|
onInviteToRoom: (userId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -24,14 +30,74 @@
|
|||||||
activeRoom,
|
activeRoom,
|
||||||
showNewRoom = $bindable(),
|
showNewRoom = $bindable(),
|
||||||
fRoom = $bindable(),
|
fRoom = $bindable(),
|
||||||
|
fRoomKind = $bindable(),
|
||||||
|
unreadCounts,
|
||||||
onSelectRoom,
|
onSelectRoom,
|
||||||
onCreateRoom,
|
onCreateRoom,
|
||||||
onSignout,
|
onSignout,
|
||||||
onShowMenu,
|
onShowMenu,
|
||||||
onUpdateProfile,
|
onUpdateProfile,
|
||||||
onAddContact,
|
onAddContact,
|
||||||
|
onSearchUsers,
|
||||||
|
onStartDirectMessage,
|
||||||
|
onInviteToRoom,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
function roomLabel(room: Room): string {
|
||||||
|
if (room.kind === 'direct') return room.other_user?.username ?? room.name ?? 'direct message';
|
||||||
|
return room.name ?? 'untitled';
|
||||||
|
}
|
||||||
|
|
||||||
|
const minSidebarWidth = 228;
|
||||||
|
const maxSidebarWidth = 440;
|
||||||
|
let sidebarWidth = $state(282);
|
||||||
|
let resizing = $state(false);
|
||||||
|
|
||||||
|
function clampSidebarWidth(width: number) {
|
||||||
|
return Math.min(maxSidebarWidth, Math.max(minSidebarWidth, width));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSidebarWidth(width: number) {
|
||||||
|
sidebarWidth = clampSidebarWidth(width);
|
||||||
|
localStorage.setItem('oxyde.sidebarWidth', String(sidebarWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizeMove(e: PointerEvent) {
|
||||||
|
if (!resizing) return;
|
||||||
|
setSidebarWidth(e.clientX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopResize() {
|
||||||
|
resizing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(e: PointerEvent) {
|
||||||
|
resizing = true;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizeKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSidebarWidth(sidebarWidth - 16);
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSidebarWidth(sidebarWidth + 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const stored = Number(localStorage.getItem('oxyde.sidebarWidth'));
|
||||||
|
if (Number.isFinite(stored)) sidebarWidth = clampSidebarWidth(stored);
|
||||||
|
window.addEventListener('pointermove', onResizeMove);
|
||||||
|
window.addEventListener('pointerup', stopResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('pointermove', onResizeMove);
|
||||||
|
window.removeEventListener('pointerup', stopResize);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Profile edit ──────────────────────────────────────────────────────────
|
// ── Profile edit ──────────────────────────────────────────────────────────
|
||||||
let showEditProfile = $state(false);
|
let showEditProfile = $state(false);
|
||||||
let fProfileUsername = $state('');
|
let fProfileUsername = $state('');
|
||||||
@@ -58,21 +124,56 @@
|
|||||||
|
|
||||||
// ── Add contact ───────────────────────────────────────────────────────────
|
// ── Add contact ───────────────────────────────────────────────────────────
|
||||||
let showAddContact = $state(false);
|
let showAddContact = $state(false);
|
||||||
let fContactId = $state('');
|
let fContactQuery = $state('');
|
||||||
|
let searchResults = $state<UserSearchResult[]>([]);
|
||||||
|
let searchBusy = $state(false);
|
||||||
let contactErr = $state('');
|
let contactErr = $state('');
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
async function submitContact() {
|
async function runUserSearch() {
|
||||||
if (!fContactId.trim()) return;
|
const query = fContactQuery.trim();
|
||||||
|
searchResults = [];
|
||||||
|
if (query.length < 2) return;
|
||||||
|
contactErr = '';
|
||||||
|
searchBusy = true;
|
||||||
|
try {
|
||||||
|
searchResults = await onSearchUsers(query);
|
||||||
|
} catch (e) { contactErr = String(e); }
|
||||||
|
finally { searchBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleUserSearch() {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(runUserSearch, 220);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitContact(userId: string) {
|
||||||
contactErr = '';
|
contactErr = '';
|
||||||
try {
|
try {
|
||||||
await onAddContact(fContactId.trim());
|
await onAddContact(userId);
|
||||||
fContactId = '';
|
fContactQuery = '';
|
||||||
|
searchResults = [];
|
||||||
showAddContact = false;
|
showAddContact = false;
|
||||||
} catch (e) { contactErr = String(e); }
|
} catch (e) { contactErr = String(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startDm(userId: string) {
|
||||||
|
contactErr = '';
|
||||||
|
try {
|
||||||
|
await onStartDirectMessage(userId);
|
||||||
|
showAddContact = false;
|
||||||
|
} catch (e) { contactErr = String(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invite(userId: string) {
|
||||||
|
contactErr = '';
|
||||||
|
try {
|
||||||
|
await onInviteToRoom(userId);
|
||||||
|
} catch (e) { contactErr = String(e); }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="sidebar">
|
<aside class="sidebar" class:resizing style="width:{sidebarWidth}px; min-width:{sidebarWidth}px">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="sidebar-head">
|
<div class="sidebar-head">
|
||||||
@@ -85,31 +186,62 @@
|
|||||||
|
|
||||||
<!-- New room form -->
|
<!-- New room form -->
|
||||||
{#if showNewRoom}
|
{#if showNewRoom}
|
||||||
<div class="new-room-form">
|
<div class="panel-form">
|
||||||
|
<div class="panel-title">new room</div>
|
||||||
<input class="field-sm" placeholder="room name" bind:value={fRoom}
|
<input class="field-sm" placeholder="room name" bind:value={fRoom}
|
||||||
onkeydown={(e) => e.key === 'Enter' && onCreateRoom()} />
|
onkeydown={(e) => e.key === 'Enter' && onCreateRoom()} />
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="segmented" aria-label="room visibility">
|
||||||
|
<button class:active={fRoomKind === 'public'} onclick={() => fRoomKind = 'public'}>public</button>
|
||||||
|
<button class:active={fRoomKind === 'private'} onclick={() => fRoomKind = 'private'}>private</button>
|
||||||
|
</div>
|
||||||
<button class="btn-xs" onclick={onCreateRoom}>create</button>
|
<button class="btn-xs" onclick={onCreateRoom}>create</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Rooms -->
|
<!-- Rooms -->
|
||||||
<div class="section-label">ROOMS</div>
|
<div class="section-label">ROOMS</div>
|
||||||
<nav class="room-list">
|
<nav class="room-list">
|
||||||
{#each rooms as room (full(room.id))}
|
{#each rooms.filter((room) => room.kind !== 'direct') as room (full(room.id))}
|
||||||
<button
|
<button
|
||||||
class="room-item"
|
class="room-item"
|
||||||
class:active={activeRoom && full(room.id) === full(activeRoom.id)}
|
class:active={activeRoom && full(room.id) === full(activeRoom.id)}
|
||||||
onclick={() => onSelectRoom(room)}
|
onclick={() => onSelectRoom(room)}
|
||||||
oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy room name', action: () => navigator.clipboard.writeText(room.name) }])}
|
oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy room name', action: () => navigator.clipboard.writeText(roomLabel(room)) }])}
|
||||||
>
|
>
|
||||||
<span class="hash">#</span>
|
<span class="hash">{room.kind === 'direct' ? '@' : '#'}</span>
|
||||||
<span class="room-name">{room.name}</span>
|
<span class="room-name">{roomLabel(room)}</span>
|
||||||
|
{#if unreadCounts[sid(room.id)]}
|
||||||
|
<span class="unread">{unreadCounts[sid(room.id)]}</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="list-empty">no rooms — create one above</p>
|
<p class="list-empty">no rooms — create one above</p>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Direct messages -->
|
||||||
|
<div class="section-label">DIRECT</div>
|
||||||
|
<nav class="dm-list">
|
||||||
|
{#each rooms.filter((room) => room.kind === 'direct') as room (full(room.id))}
|
||||||
|
<button
|
||||||
|
class="room-item"
|
||||||
|
class:active={activeRoom && full(room.id) === full(activeRoom.id)}
|
||||||
|
onclick={() => onSelectRoom(room)}
|
||||||
|
oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy name', action: () => navigator.clipboard.writeText(roomLabel(room)) }])}
|
||||||
|
>
|
||||||
|
<span class="hash">@</span>
|
||||||
|
<span class="room-name">{roomLabel(room)}</span>
|
||||||
|
{#if unreadCounts[sid(room.id)]}
|
||||||
|
<span class="unread">{unreadCounts[sid(room.id)]}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="list-empty">no direct messages</p>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- Contacts -->
|
<!-- Contacts -->
|
||||||
<div class="section-label-row">
|
<div class="section-label-row">
|
||||||
<span class="section-label">CONTACTS</span>
|
<span class="section-label">CONTACTS</span>
|
||||||
@@ -118,19 +250,44 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if showAddContact}
|
{#if showAddContact}
|
||||||
<div class="new-room-form">
|
<div class="panel-form">
|
||||||
<input class="field-sm" placeholder="user id" bind:value={fContactId}
|
<div class="panel-title">find people</div>
|
||||||
onkeydown={(e) => e.key === 'Enter' && submitContact()} />
|
<input class="field-sm" placeholder="search username" bind:value={fContactQuery}
|
||||||
<button class="btn-xs" onclick={submitContact}>add</button>
|
oninput={scheduleUserSearch}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && runUserSearch()} />
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="helper-text">2+ characters</span>
|
||||||
|
<button class="btn-xs" onclick={runUserSearch} disabled={searchBusy}>
|
||||||
|
{searchBusy ? '...' : 'find'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if contactErr}<p class="form-err" style="padding: 0 12px 6px">{contactErr}</p>{/if}
|
{#if contactErr}<p class="form-err" style="padding: 0 12px 6px">{contactErr}</p>{/if}
|
||||||
|
{#if searchResults.length > 0}
|
||||||
|
<div class="search-results">
|
||||||
|
{#each searchResults as result (full(result.id))}
|
||||||
|
<div class="search-result">
|
||||||
|
<span class="avatar mini">{result.username[0]?.toUpperCase() ?? '?'}</span>
|
||||||
|
<span class="contact-name">{result.username}</span>
|
||||||
|
<div class="row-actions">
|
||||||
|
{#if activeRoom && activeRoom.kind !== 'direct'}
|
||||||
|
<button class="mini-action" title="Invite" onclick={() => invite(sid(result.id))}>invite</button>
|
||||||
|
{/if}
|
||||||
|
<button class="mini-action" title="Add contact" onclick={() => submitContact(sid(result.id))}>add</button>
|
||||||
|
<button class="mini-action primary" title="Message" onclick={() => startDm(sid(result.id))}>msg</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if contacts.length > 0}
|
{#if contacts.length > 0}
|
||||||
<div class="contact-list">
|
<div class="contact-list">
|
||||||
{#each contacts as c}
|
{#each contacts as c (full(c.id))}
|
||||||
<div class="contact-item">
|
<div class="contact-item">
|
||||||
<span class="presence online"></span>
|
<span class="presence online"></span>
|
||||||
<span class="contact-name">{c.username}</span>
|
<span class="contact-name">{c.username}</span>
|
||||||
|
<button class="mini-action contact-action" onclick={() => startDm(sid(c.id))}>msg</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -166,16 +323,39 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="resize-handle"
|
||||||
|
aria-label="Resize sidebar"
|
||||||
|
title="Resize sidebar"
|
||||||
|
onpointerdown={startResize}
|
||||||
|
onkeydown={onResizeKey}
|
||||||
|
></button>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 282px; min-width: 282px;
|
position: relative;
|
||||||
background: var(--sidebar-bg);
|
background: var(--sidebar-bg);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.sidebar.resizing,
|
||||||
|
.sidebar.resizing * { cursor: col-resize; user-select: none; }
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute; top: 0; right: -3px; bottom: 0;
|
||||||
|
width: 6px; cursor: col-resize; z-index: 4;
|
||||||
|
padding: 0; border: 0; background: transparent;
|
||||||
|
}
|
||||||
|
.resize-handle::after {
|
||||||
|
content: ''; position: absolute; top: 0; right: 2px; bottom: 0;
|
||||||
|
width: 1px; background: transparent;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.resize-handle:hover::after,
|
||||||
|
.resize-handle:focus-visible::after,
|
||||||
|
.sidebar.resizing .resize-handle::after { background: var(--accent); }
|
||||||
.sidebar-head {
|
.sidebar-head {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 16px 14px 14px;
|
padding: 16px 14px 14px;
|
||||||
@@ -198,12 +378,16 @@
|
|||||||
.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
|
.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
.icon-btn.signout:hover { border-color: var(--danger); color: var(--danger); }
|
.icon-btn.signout:hover { border-color: var(--danger); color: var(--danger); }
|
||||||
|
|
||||||
.new-room-form {
|
.panel-form {
|
||||||
display: flex; gap: 6px; align-items: center;
|
display: flex; flex-direction: column; gap: 7px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px 11px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
animation: rise 0.15s ease;
|
animation: rise 0.15s ease;
|
||||||
}
|
}
|
||||||
|
.panel-title {
|
||||||
|
font-size: 9px; letter-spacing: 0.14em;
|
||||||
|
color: var(--muted); text-transform: uppercase;
|
||||||
|
}
|
||||||
@keyframes rise {
|
@keyframes rise {
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
@@ -225,6 +409,30 @@
|
|||||||
transition: opacity 0.12s;
|
transition: opacity 0.12s;
|
||||||
}
|
}
|
||||||
.btn-xs:hover { opacity: 0.82; }
|
.btn-xs:hover { opacity: 0.82; }
|
||||||
|
.btn-xs:disabled { opacity: 0.45; cursor: wait; }
|
||||||
|
.form-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 7px; min-width: 0;
|
||||||
|
}
|
||||||
|
.helper-text {
|
||||||
|
color: var(--muted); font-size: 10px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.segmented {
|
||||||
|
display: flex; min-width: 0;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--r);
|
||||||
|
overflow: hidden; background: var(--bg);
|
||||||
|
}
|
||||||
|
.segmented button {
|
||||||
|
padding: 5px 8px; background: transparent; border: none;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
color: var(--muted); font-family: inherit; font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.segmented button:last-child { border-right: 0; }
|
||||||
|
.segmented button.active {
|
||||||
|
background: var(--accent-soft); color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.section-label {
|
.section-label {
|
||||||
padding: 14px 14px 5px;
|
padding: 14px 14px 5px;
|
||||||
@@ -232,8 +440,10 @@
|
|||||||
color: var(--muted); font-weight: 500;
|
color: var(--muted); font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-list { flex: 1; overflow-y: auto; padding: 3px 8px; }
|
.room-list { flex: 1; min-height: 70px; overflow-y: auto; padding: 3px 8px; }
|
||||||
.room-list::-webkit-scrollbar { width: 0; }
|
.dm-list { max-height: 28%; overflow-y: auto; padding: 3px 8px; flex-shrink: 0; }
|
||||||
|
.room-list::-webkit-scrollbar,
|
||||||
|
.dm-list::-webkit-scrollbar { width: 0; }
|
||||||
|
|
||||||
.room-item {
|
.room-item {
|
||||||
display: flex; align-items: center; gap: 5px;
|
display: flex; align-items: center; gap: 5px;
|
||||||
@@ -253,12 +463,42 @@
|
|||||||
.hash { color: var(--muted); font-size: 14px; flex-shrink: 0; }
|
.hash { color: var(--muted); font-size: 14px; flex-shrink: 0; }
|
||||||
.room-item.active .hash { color: var(--accent); }
|
.room-item.active .hash { color: var(--accent); }
|
||||||
.room-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.room-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.unread {
|
||||||
|
min-width: 18px; height: 18px; margin-left: auto;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--r); background: var(--accent); color: #fff;
|
||||||
|
font-size: 10px; padding: 0 5px;
|
||||||
|
}
|
||||||
.list-empty { padding: 8px 7px; color: var(--muted); font-size: 10.5px; }
|
.list-empty { padding: 8px 7px; color: var(--muted); font-size: 10.5px; }
|
||||||
|
|
||||||
.contact-list { padding: 3px 8px; }
|
.contact-list { padding: 3px 8px; max-height: 24%; overflow-y: auto; flex-shrink: 0; }
|
||||||
.contact-item {
|
.contact-item {
|
||||||
display: flex; align-items: center; gap: 7px;
|
display: flex; align-items: center; gap: 7px;
|
||||||
padding: 5px 7px; color: var(--muted); font-size: 12px;
|
padding: 5px 7px; color: var(--muted); font-size: 12px;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.contact-item:hover { background: var(--surface); color: var(--text-2); }
|
||||||
|
.contact-action { margin-left: auto; }
|
||||||
|
.search-results {
|
||||||
|
padding: 4px 8px 8px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.search-result {
|
||||||
|
display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center; gap: 7px;
|
||||||
|
padding: 5px 4px; color: var(--text-2); font-size: 11px;
|
||||||
|
}
|
||||||
|
.row-actions { display: flex; gap: 4px; justify-content: flex-end; }
|
||||||
|
.mini-action {
|
||||||
|
padding: 3px 6px; background: transparent;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--r);
|
||||||
|
color: var(--muted); font-family: inherit; font-size: 10px;
|
||||||
|
cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mini-action:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.mini-action.primary {
|
||||||
|
background: var(--accent-soft); border-color: rgba(181, 98, 26, 0.28);
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.presence {
|
.presence {
|
||||||
width: 6px; height: 6px; border-radius: 50%;
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
@@ -292,7 +532,6 @@
|
|||||||
border-top: 1px solid var(--border-subtle);
|
border-top: 1px solid var(--border-subtle);
|
||||||
animation: rise 0.15s ease;
|
animation: rise 0.15s ease;
|
||||||
}
|
}
|
||||||
.form-row { display: flex; gap: 6px; }
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
background: transparent; border: 1px solid var(--border); color: var(--muted);
|
background: transparent; border: 1px solid var(--border); color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -307,6 +546,7 @@
|
|||||||
background: var(--accent); border-radius: var(--r);
|
background: var(--accent); border-radius: var(--r);
|
||||||
color: #fff; font-size: 11px; font-weight: 600;
|
color: #fff; font-size: 11px; font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.avatar.mini { width: 20px; height: 20px; font-size: 9px; }
|
||||||
.user-name {
|
.user-name {
|
||||||
font-size: 12px; color: var(--text-2);
|
font-size: 12px; color: var(--text-2);
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export interface User { id: any; username: string; email: string; avatar?: string; created: string; }
|
export interface User { id: any; username: string; email?: string; avatar?: string; created?: string; }
|
||||||
export interface Room { id: any; name: string; created: string; }
|
export interface Room { id: any; name?: string; kind?: 'public' | 'private' | 'direct'; direct_key?: string; created: string; updated?: string; created_by?: any; last_message?: Message; unread_count?: number; other_user?: User; }
|
||||||
export interface Message { id: any; room: any; author: any; author_username?: string; body: string; created: string; }
|
export interface RoomMember { id: any; room: any; user: any; role: 'owner' | 'member'; joined: string; last_read_at?: string; muted?: boolean; }
|
||||||
|
export interface MessageReactionSummary { emoji: string; count: number; reacted_by_me: boolean; }
|
||||||
|
export interface Message { id: any; room: any; author: any; author_username?: string; body: string; created: string; updated?: string; deleted?: boolean; reply_to?: any; reactions?: MessageReactionSummary[]; }
|
||||||
|
export interface UserSearchResult { id: any; username: string; avatar?: string; }
|
||||||
export interface LiveEvent { action: 'Create' | 'Update' | 'Delete'; data: Message; }
|
export interface LiveEvent { action: 'Create' | 'Update' | 'Delete'; data: Message; }
|
||||||
export interface ContextMenuItem { label: string; action: () => void; }
|
export interface ContextMenuItem { label: string; action: () => void; }
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from "svelte";
|
||||||
import LoadingScreen from '$lib/components/LoadingScreen.svelte';
|
import LoadingScreen from "$lib/components/LoadingScreen.svelte";
|
||||||
import AuthCard from '$lib/components/AuthCard.svelte';
|
import AuthCard from "$lib/components/AuthCard.svelte";
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from "$lib/components/Sidebar.svelte";
|
||||||
import ChatMain from '$lib/components/ChatMain.svelte';
|
import ChatMain from "$lib/components/ChatMain.svelte";
|
||||||
import ContextMenu from '$lib/components/ContextMenu.svelte';
|
import ContextMenu from "$lib/components/ContextMenu.svelte";
|
||||||
import type { User, Room, Message, LiveEvent, ContextMenuItem } from '$lib/types';
|
import type {
|
||||||
import { sid, full, cmd } from '$lib/helpers';
|
User,
|
||||||
|
Room,
|
||||||
|
Message,
|
||||||
|
LiveEvent,
|
||||||
|
ContextMenuItem,
|
||||||
|
UserSearchResult,
|
||||||
|
} from "$lib/types";
|
||||||
|
import { sid, full, cmd } from "$lib/helpers";
|
||||||
|
|
||||||
// ─── State ────────────────────────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────────────────────
|
||||||
let user = $state<User | null>(null);
|
let user = $state<User | null>(null);
|
||||||
@@ -16,17 +23,28 @@
|
|||||||
let contacts = $state<User[]>([]);
|
let contacts = $state<User[]>([]);
|
||||||
let subId = $state<string | null>(null);
|
let subId = $state<string | null>(null);
|
||||||
let unlisten = $state<(() => void) | null>(null);
|
let unlisten = $state<(() => void) | null>(null);
|
||||||
|
let hasOlderMessages = $state(false);
|
||||||
|
let isLoadingOlder = $state(false);
|
||||||
|
let unreadCounts = $state<Record<string, number>>({});
|
||||||
|
|
||||||
let view = $state<'loading' | 'auth' | 'app'>('loading');
|
let view = $state<"loading" | "auth" | "app">("loading");
|
||||||
let authMode = $state<'signin' | 'signup'>('signin');
|
let authMode = $state<"signin" | "signup">("signin");
|
||||||
let showNewRoom = $state(false);
|
let showNewRoom = $state(false);
|
||||||
let err = $state('');
|
let err = $state("");
|
||||||
|
|
||||||
let fEmail = $state(''); let fPass = $state('');
|
let fEmail = $state("");
|
||||||
let fUser = $state(''); let fMsg = $state('');
|
let fPass = $state("");
|
||||||
let fRoom = $state('');
|
let fUser = $state("");
|
||||||
|
let fMsg = $state("");
|
||||||
|
let fRoom = $state("");
|
||||||
|
let fRoomKind = $state<"public" | "private">("public");
|
||||||
|
let replyTo = $state<Message | null>(null);
|
||||||
|
|
||||||
let contextMenu = $state<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
|
let contextMenu = $state<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
items: ContextMenuItem[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
function showMenu(e: MouseEvent, items: ContextMenuItem[]) {
|
function showMenu(e: MouseEvent, items: ContextMenuItem[]) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -36,115 +54,279 @@
|
|||||||
// ─── Auth ─────────────────────────────────────────────────────────────────
|
// ─── Auth ─────────────────────────────────────────────────────────────────
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
user = await cmd<User>('restore_session');
|
user = await cmd<User>("restore_session");
|
||||||
view = 'app';
|
view = "app";
|
||||||
await loadRooms();
|
await loadRooms();
|
||||||
contacts = await cmd<User[]>('get_contacts').catch(() => []);
|
contacts = await cmd<User[]>("get_contacts").catch(() => []);
|
||||||
|
requestNotificationPermission();
|
||||||
} catch {
|
} catch {
|
||||||
view = 'auth';
|
view = "auth";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signin() {
|
async function signin() {
|
||||||
err = '';
|
err = "";
|
||||||
try {
|
try {
|
||||||
await cmd('signin', { email: fEmail, password: fPass });
|
await cmd("signin", { email: fEmail, password: fPass });
|
||||||
user = await cmd<User>('get_me');
|
user = await cmd<User>("get_me");
|
||||||
view = 'app';
|
view = "app";
|
||||||
await loadRooms();
|
await loadRooms();
|
||||||
contacts = await cmd<User[]>('get_contacts').catch(() => []);
|
contacts = await cmd<User[]>("get_contacts").catch(() => []);
|
||||||
} catch (e) { err = String(e); }
|
requestNotificationPermission();
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signup() {
|
async function signup() {
|
||||||
err = '';
|
err = "";
|
||||||
try {
|
try {
|
||||||
user = await cmd<User>('signup', { email: fEmail, username: fUser, password: fPass });
|
user = await cmd<User>("signup", {
|
||||||
view = 'app';
|
email: fEmail,
|
||||||
|
username: fUser,
|
||||||
|
password: fPass,
|
||||||
|
});
|
||||||
|
view = "app";
|
||||||
await loadRooms();
|
await loadRooms();
|
||||||
} catch (e) { err = String(e); }
|
requestNotificationPermission();
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestNotificationPermission() {
|
||||||
|
if ("Notification" in window && Notification.permission === "default") {
|
||||||
|
Notification.requestPermission().catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signout() {
|
async function signout() {
|
||||||
await cmd('signout').catch(() => {});
|
await cmd("signout").catch(() => {});
|
||||||
if (subId) { await cmd('unsubscribe_room', { subId }).catch(() => {}); subId = null; }
|
if (subId) {
|
||||||
if (unlisten){ unlisten(); unlisten = null; }
|
await cmd("unsubscribe_room", { subId }).catch(() => {});
|
||||||
user = null; rooms = []; messages = []; activeRoom = null;
|
subId = null;
|
||||||
view = 'auth';
|
}
|
||||||
|
if (unlisten) {
|
||||||
|
unlisten();
|
||||||
|
unlisten = null;
|
||||||
|
}
|
||||||
|
user = null;
|
||||||
|
rooms = [];
|
||||||
|
messages = [];
|
||||||
|
activeRoom = null;
|
||||||
|
unreadCounts = {};
|
||||||
|
view = "auth";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Rooms ────────────────────────────────────────────────────────────────
|
// ─── Rooms ────────────────────────────────────────────────────────────────
|
||||||
async function loadRooms() {
|
async function loadRooms() {
|
||||||
rooms = await cmd<Room[]>('get_rooms');
|
rooms = await cmd<Room[]>("get_rooms");
|
||||||
if (rooms.length && !activeRoom) await selectRoom(rooms[0]);
|
if (rooms.length && !activeRoom) await selectRoom(rooms[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectRoom(room: Room) {
|
async function selectRoom(room: Room) {
|
||||||
if (subId) { await cmd('unsubscribe_room', { subId }).catch(() => {}); subId = null; }
|
if (subId) {
|
||||||
if (unlisten){ unlisten(); unlisten = null; }
|
await cmd("unsubscribe_room", { subId }).catch(() => {});
|
||||||
|
subId = null;
|
||||||
|
}
|
||||||
|
if (unlisten) {
|
||||||
|
unlisten();
|
||||||
|
unlisten = null;
|
||||||
|
}
|
||||||
|
|
||||||
activeRoom = room;
|
activeRoom = room;
|
||||||
messages = await cmd<Message[]>('get_messages', { roomId: sid(room.id) });
|
replyTo = null;
|
||||||
|
messages = await cmd<Message[]>("get_messages", {
|
||||||
subId = await cmd<string>('subscribe_room', { roomId: sid(room.id) });
|
roomId: sid(room.id),
|
||||||
const { listen } = await import('@tauri-apps/api/event');
|
limit: 50,
|
||||||
unlisten = await listen<LiveEvent>('chat:message', ({ payload }) => {
|
|
||||||
const { action, data } = payload;
|
|
||||||
if (action === 'Create') { messages = [...messages, data]; }
|
|
||||||
else if (action === 'Delete') { messages = messages.filter(m => full(m.id) !== full(data.id)); }
|
|
||||||
else if (action === 'Update') { messages = messages.map(m => full(m.id) === full(data.id) ? data : m); }
|
|
||||||
});
|
});
|
||||||
|
hasOlderMessages = messages.length === 50;
|
||||||
|
unreadCounts = { ...unreadCounts, [sid(room.id)]: 0 };
|
||||||
|
await cmd("mark_room_read", { roomId: sid(room.id) }).catch(() => {});
|
||||||
|
|
||||||
|
subId = await cmd<string>("subscribe_room", { roomId: sid(room.id) });
|
||||||
|
const { listen } = await import("@tauri-apps/api/event");
|
||||||
|
unlisten = await listen<LiveEvent>("chat:message", ({ payload }) => {
|
||||||
|
const { action, data } = payload;
|
||||||
|
const eventRoomId = sid(data.room);
|
||||||
|
const currentRoomId = activeRoom ? sid(activeRoom.id) : "";
|
||||||
|
if (eventRoomId !== currentRoomId) {
|
||||||
|
unreadCounts = {
|
||||||
|
...unreadCounts,
|
||||||
|
[eventRoomId]: (unreadCounts[eventRoomId] ?? 0) + 1,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
"Notification" in window &&
|
||||||
|
Notification.permission === "granted" &&
|
||||||
|
document.hidden
|
||||||
|
) {
|
||||||
|
new Notification(data.author_username ?? "New message", {
|
||||||
|
body: data.body || "New message",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "Create") {
|
||||||
|
messages = [...messages, data];
|
||||||
|
} else if (action === "Delete") {
|
||||||
|
messages = messages.filter((m) => full(m.id) !== full(data.id));
|
||||||
|
} else if (action === "Update") {
|
||||||
|
messages = messages.map((m) =>
|
||||||
|
full(m.id) === full(data.id) ? data : m,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cmd("mark_room_read", { roomId: currentRoomId }).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOlderMessages() {
|
||||||
|
if (
|
||||||
|
!activeRoom ||
|
||||||
|
isLoadingOlder ||
|
||||||
|
!hasOlderMessages ||
|
||||||
|
messages.length === 0
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
isLoadingOlder = true;
|
||||||
|
try {
|
||||||
|
const older = await cmd<Message[]>("get_messages", {
|
||||||
|
roomId: sid(activeRoom.id),
|
||||||
|
before: messages[0].created,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
messages = [...older, ...messages];
|
||||||
|
hasOlderMessages = older.length === 50;
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
} finally {
|
||||||
|
isLoadingOlder = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createRoom() {
|
async function createRoom() {
|
||||||
if (!fRoom.trim()) return;
|
if (!fRoom.trim()) return;
|
||||||
err = '';
|
err = "";
|
||||||
try {
|
try {
|
||||||
const r = await cmd<Room>('create_room', { name: fRoom.trim() });
|
const r = await cmd<Room>("create_room", {
|
||||||
|
name: fRoom.trim(),
|
||||||
|
kind: fRoomKind,
|
||||||
|
});
|
||||||
rooms = [r, ...rooms];
|
rooms = [r, ...rooms];
|
||||||
fRoom = ''; showNewRoom = false;
|
fRoom = "";
|
||||||
|
showNewRoom = false;
|
||||||
await selectRoom(r);
|
await selectRoom(r);
|
||||||
} catch (e) { err = String(e); }
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Messages ─────────────────────────────────────────────────────────────
|
// ─── Messages ─────────────────────────────────────────────────────────────
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
if (!fMsg.trim() || !activeRoom) return;
|
if (!fMsg.trim() || !activeRoom) return;
|
||||||
err = '';
|
err = "";
|
||||||
try {
|
try {
|
||||||
await cmd('send_message', { roomId: sid(activeRoom.id), body: fMsg.trim() });
|
await cmd("send_message", {
|
||||||
fMsg = '';
|
roomId: sid(activeRoom.id),
|
||||||
} catch (e) { err = String(e); }
|
body: fMsg.trim(),
|
||||||
|
replyTo: replyTo ? sid(replyTo.id) : null,
|
||||||
|
});
|
||||||
|
fMsg = "";
|
||||||
|
replyTo = null;
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteMessage(msgId: string) {
|
async function deleteMessage(msgId: string) {
|
||||||
err = '';
|
err = "";
|
||||||
try {
|
try {
|
||||||
await cmd('delete_message', { messageId: msgId });
|
await cmd("delete_message", { messageId: msgId });
|
||||||
messages = messages.filter(m => full(m.id) !== msgId);
|
messages = messages.filter((m) => full(m.id) !== msgId);
|
||||||
} catch (e) { err = String(e); }
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateProfile(fields: { username?: string; avatar?: string }) {
|
async function editMessage(msgId: string, body: string) {
|
||||||
user = await cmd<User>('update_profile', fields);
|
err = "";
|
||||||
|
try {
|
||||||
|
const updated = await cmd<Message>("edit_message", {
|
||||||
|
messageId: msgId,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
messages = messages.map((m) =>
|
||||||
|
full(m.id) === full(updated.id) ? updated : m,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleReaction(msgId: string, emoji: string) {
|
||||||
|
err = "";
|
||||||
|
try {
|
||||||
|
await cmd("toggle_reaction", { messageId: msgId, emoji });
|
||||||
|
if (activeRoom) {
|
||||||
|
messages = await cmd<Message[]>("get_messages", {
|
||||||
|
roomId: sid(activeRoom.id),
|
||||||
|
limit: Math.max(50, Math.min(messages.length, 100)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfile(fields: {
|
||||||
|
username?: string;
|
||||||
|
avatar?: string;
|
||||||
|
}) {
|
||||||
|
user = await cmd<User>("update_profile", fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addContact(userId: string) {
|
async function addContact(userId: string) {
|
||||||
await cmd('add_contact', { userId });
|
await cmd("add_contact", { userId });
|
||||||
contacts = await cmd<User[]>('get_contacts').catch(() => []);
|
contacts = await cmd<User[]>("get_contacts").catch(() => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchUsers(query: string) {
|
||||||
|
return await cmd<UserSearchResult[]>("search_users", { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDirectMessage(userId: string) {
|
||||||
|
err = "";
|
||||||
|
try {
|
||||||
|
const room = await cmd<Room>("get_or_create_direct_room", {
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
if (!rooms.some((r) => full(r.id) === full(room.id)))
|
||||||
|
rooms = [room, ...rooms];
|
||||||
|
await selectRoom(room);
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inviteToActiveRoom(userId: string) {
|
||||||
|
if (!activeRoom || activeRoom.kind === "direct") return;
|
||||||
|
err = "";
|
||||||
|
try {
|
||||||
|
await cmd("invite_to_room", { roomId: sid(activeRoom.id), userId });
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(init);
|
onMount(init);
|
||||||
onDestroy(async () => {
|
onDestroy(async () => {
|
||||||
if (subId) await cmd('unsubscribe_room', { subId }).catch(() => {});
|
if (subId) await cmd("unsubscribe_room", { subId }).catch(() => {});
|
||||||
if (unlisten) unlisten();
|
if (unlisten) unlisten();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if view === 'loading'}
|
{#if view === "loading"}
|
||||||
<LoadingScreen />
|
<LoadingScreen />
|
||||||
|
{:else if view === "auth"}
|
||||||
{:else if view === 'auth'}
|
|
||||||
<AuthCard
|
<AuthCard
|
||||||
{authMode}
|
{authMode}
|
||||||
{err}
|
{err}
|
||||||
@@ -153,9 +335,11 @@
|
|||||||
bind:fUser
|
bind:fUser
|
||||||
onSignin={signin}
|
onSignin={signin}
|
||||||
onSignup={signup}
|
onSignup={signup}
|
||||||
onToggleMode={() => { authMode = authMode === 'signin' ? 'signup' : 'signin'; err = ''; }}
|
onToggleMode={() => {
|
||||||
|
authMode = authMode === "signin" ? "signup" : "signin";
|
||||||
|
err = "";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -165,21 +349,32 @@
|
|||||||
{activeRoom}
|
{activeRoom}
|
||||||
bind:showNewRoom
|
bind:showNewRoom
|
||||||
bind:fRoom
|
bind:fRoom
|
||||||
|
bind:fRoomKind
|
||||||
|
{unreadCounts}
|
||||||
onSelectRoom={selectRoom}
|
onSelectRoom={selectRoom}
|
||||||
onCreateRoom={createRoom}
|
onCreateRoom={createRoom}
|
||||||
onSignout={signout}
|
onSignout={signout}
|
||||||
onShowMenu={showMenu}
|
onShowMenu={showMenu}
|
||||||
onUpdateProfile={updateProfile}
|
onUpdateProfile={updateProfile}
|
||||||
onAddContact={addContact}
|
onAddContact={addContact}
|
||||||
|
onSearchUsers={searchUsers}
|
||||||
|
onStartDirectMessage={startDirectMessage}
|
||||||
|
onInviteToRoom={inviteToActiveRoom}
|
||||||
/>
|
/>
|
||||||
<ChatMain
|
<ChatMain
|
||||||
{activeRoom}
|
{activeRoom}
|
||||||
{messages}
|
{messages}
|
||||||
{user}
|
{user}
|
||||||
{err}
|
{err}
|
||||||
|
{hasOlderMessages}
|
||||||
|
{isLoadingOlder}
|
||||||
bind:fMsg
|
bind:fMsg
|
||||||
|
bind:replyTo
|
||||||
|
onLoadOlderMessages={loadOlderMessages}
|
||||||
onSendMessage={sendMessage}
|
onSendMessage={sendMessage}
|
||||||
onDeleteMessage={deleteMessage}
|
onDeleteMessage={deleteMessage}
|
||||||
|
onEditMessage={editMessage}
|
||||||
|
onToggleReaction={toggleReaction}
|
||||||
onShowMenu={showMenu}
|
onShowMenu={showMenu}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,18 +383,24 @@
|
|||||||
x={contextMenu.x}
|
x={contextMenu.x}
|
||||||
y={contextMenu.y}
|
y={contextMenu.y}
|
||||||
items={contextMenu.items}
|
items={contextMenu.items}
|
||||||
onclose={() => contextMenu = null}
|
onclose={() => (contextMenu = null)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ─── Reset & base ──────────────────────────────────────────────────────── */
|
/* ─── Reset & base ──────────────────────────────────────────────────────── */
|
||||||
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
|
:global(*, *::before, *::after) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
:global(html, body) {
|
:global(html, body) {
|
||||||
width: 100%; height: 100%; overflow: hidden;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
background: #09090b;
|
background: #09090b;
|
||||||
font-family: 'Martian Mono', 'Courier New', monospace;
|
font-family: "Martian Mono", "Courier New", monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #ddd8d0;
|
color: #ddd8d0;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -225,11 +426,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
display: flex; height: 100vh; width: 100%;
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
animation: rise 0.2s ease;
|
animation: rise 0.2s ease;
|
||||||
}
|
}
|
||||||
@keyframes rise {
|
@keyframes rise {
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
2
surreal/connect-database.sh
Executable file
2
surreal/connect-database.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
surreal sql -e http://127.0.0.1:8000 --namespace dev --database oxyde --user root --pass root
|
||||||
1
surreal/import-database.sh
Executable file
1
surreal/import-database.sh
Executable file
@@ -0,0 +1 @@
|
|||||||
|
surreal import --endpoint http://localhost:8000 --user root --pass root --ns dev --db oxyde schema.surql
|
||||||
@@ -16,7 +16,7 @@ DEFINE ACCESS account ON DATABASE TYPE RECORD
|
|||||||
|
|
||||||
DEFINE TABLE user SCHEMAFULL
|
DEFINE TABLE user SCHEMAFULL
|
||||||
PERMISSIONS
|
PERMISSIONS
|
||||||
FOR select WHERE id = $auth OR $auth IN (SELECT owner FROM contact WHERE target = id)
|
FOR select WHERE $auth != NONE
|
||||||
FOR update WHERE id = $auth
|
FOR update WHERE id = $auth
|
||||||
FOR create NONE
|
FOR create NONE
|
||||||
FOR delete NONE;
|
FOR delete NONE;
|
||||||
@@ -26,25 +26,63 @@ DEFINE FIELD password ON user TYPE string;
|
|||||||
DEFINE FIELD avatar ON user TYPE option<string>;
|
DEFINE FIELD avatar ON user TYPE option<string>;
|
||||||
DEFINE FIELD created ON user TYPE datetime DEFAULT time::now();
|
DEFINE FIELD created ON user TYPE datetime DEFAULT time::now();
|
||||||
DEFINE INDEX email_idx ON user FIELDS email UNIQUE;
|
DEFINE INDEX email_idx ON user FIELDS email UNIQUE;
|
||||||
|
DEFINE INDEX username_idx ON user FIELDS username;
|
||||||
|
|
||||||
DEFINE TABLE room SCHEMAFULL
|
DEFINE TABLE room SCHEMAFULL
|
||||||
PERMISSIONS
|
PERMISSIONS
|
||||||
FOR select, create FULL
|
FOR select WHERE created_by = $auth OR id IN (SELECT VALUE room FROM room_member WHERE user = $auth) OR kind = "public"
|
||||||
FOR update, delete NONE;
|
FOR create FULL
|
||||||
DEFINE FIELD name ON room TYPE string;
|
FOR update WHERE id IN (SELECT VALUE room FROM room_member WHERE user = $auth AND role = "owner")
|
||||||
|
FOR delete NONE;
|
||||||
|
DEFINE FIELD name ON room TYPE option<string>;
|
||||||
|
DEFINE FIELD kind ON room TYPE string DEFAULT "public" ASSERT $value IN ["public", "private", "direct"];
|
||||||
|
DEFINE FIELD created_by ON room TYPE option<record<user>>;
|
||||||
|
DEFINE FIELD direct_key ON room TYPE option<string>;
|
||||||
DEFINE FIELD created ON room TYPE datetime DEFAULT time::now();
|
DEFINE FIELD created ON room TYPE datetime DEFAULT time::now();
|
||||||
|
DEFINE FIELD updated ON room TYPE datetime DEFAULT time::now();
|
||||||
|
DEFINE INDEX direct_key_idx ON room FIELDS direct_key UNIQUE;
|
||||||
|
|
||||||
|
DEFINE TABLE room_member SCHEMAFULL
|
||||||
|
PERMISSIONS
|
||||||
|
FOR select WHERE room IN (SELECT VALUE room FROM room_member WHERE user = $auth)
|
||||||
|
FOR create WHERE user = $auth OR room IN (SELECT VALUE room FROM room_member WHERE user = $auth AND role = "owner")
|
||||||
|
FOR update WHERE user = $auth OR room IN (SELECT VALUE room FROM room_member WHERE user = $auth AND role = "owner")
|
||||||
|
FOR delete WHERE user = $auth OR room IN (SELECT VALUE room FROM room_member WHERE user = $auth AND role = "owner");
|
||||||
|
DEFINE FIELD room ON room_member TYPE record<room>;
|
||||||
|
DEFINE FIELD user ON room_member TYPE record<user>;
|
||||||
|
DEFINE FIELD role ON room_member TYPE string DEFAULT "member" ASSERT $value IN ["owner", "member"];
|
||||||
|
DEFINE FIELD joined ON room_member TYPE datetime DEFAULT time::now();
|
||||||
|
DEFINE FIELD last_read_at ON room_member TYPE option<datetime>;
|
||||||
|
DEFINE FIELD muted ON room_member TYPE bool DEFAULT false;
|
||||||
|
DEFINE INDEX unique_room_member ON room_member FIELDS room, user UNIQUE;
|
||||||
|
|
||||||
DEFINE TABLE message SCHEMAFULL
|
DEFINE TABLE message SCHEMAFULL
|
||||||
PERMISSIONS
|
PERMISSIONS
|
||||||
FOR select FULL
|
FOR select WHERE room.kind = "public" OR room IN (SELECT VALUE room FROM room_member WHERE user = $auth)
|
||||||
FOR create WHERE author = $auth
|
FOR create WHERE author = $auth AND (room.kind = "public" OR room IN (SELECT VALUE room FROM room_member WHERE user = $auth))
|
||||||
FOR update WHERE author = $auth
|
FOR update WHERE author = $auth
|
||||||
FOR delete WHERE author = $auth;
|
FOR delete WHERE author = $auth;
|
||||||
DEFINE FIELD room ON message TYPE record<room>;
|
DEFINE FIELD room ON message TYPE record<room>;
|
||||||
DEFINE FIELD author ON message TYPE record<user>;
|
DEFINE FIELD author ON message TYPE record<user>;
|
||||||
DEFINE FIELD author_username ON message TYPE option<string>;
|
DEFINE FIELD author_username ON message TYPE option<string>;
|
||||||
DEFINE FIELD body ON message TYPE string;
|
DEFINE FIELD body ON message TYPE string ASSERT string::len($value) <= 4000;
|
||||||
DEFINE FIELD created ON message TYPE datetime DEFAULT time::now();
|
DEFINE FIELD created ON message TYPE datetime DEFAULT time::now();
|
||||||
|
DEFINE FIELD updated ON message TYPE option<datetime>;
|
||||||
|
DEFINE FIELD deleted ON message TYPE bool DEFAULT false;
|
||||||
|
DEFINE FIELD reply_to ON message TYPE option<record<message>>;
|
||||||
|
DEFINE INDEX message_room_created ON message FIELDS room, created;
|
||||||
|
|
||||||
|
DEFINE TABLE message_reaction SCHEMAFULL
|
||||||
|
PERMISSIONS
|
||||||
|
FOR select WHERE message.room.kind = "public" OR message.room IN (SELECT VALUE room FROM room_member WHERE user = $auth)
|
||||||
|
FOR create WHERE user = $auth AND (message.room.kind = "public" OR message.room IN (SELECT VALUE room FROM room_member WHERE user = $auth))
|
||||||
|
FOR update NONE
|
||||||
|
FOR delete WHERE user = $auth;
|
||||||
|
DEFINE FIELD message ON message_reaction TYPE record<message>;
|
||||||
|
DEFINE FIELD user ON message_reaction TYPE record<user>;
|
||||||
|
DEFINE FIELD emoji ON message_reaction TYPE string ASSERT string::len($value) <= 16;
|
||||||
|
DEFINE FIELD created ON message_reaction TYPE datetime DEFAULT time::now();
|
||||||
|
DEFINE INDEX unique_message_reaction ON message_reaction FIELDS message, user, emoji UNIQUE;
|
||||||
|
|
||||||
DEFINE TABLE contact SCHEMAFULL
|
DEFINE TABLE contact SCHEMAFULL
|
||||||
PERMISSIONS
|
PERMISSIONS
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
|
|||||||
Reference in New Issue
Block a user