MEga push vol idk, chat function updates, docker fixes
This commit is contained in:
@@ -11,6 +11,7 @@ README.md
|
|||||||
node_modules
|
node_modules
|
||||||
build
|
build
|
||||||
**/.env
|
**/.env
|
||||||
**/.env.*
|
**/.env.local
|
||||||
|
**/.env.*.local
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
|
# ── Supabase ──
|
||||||
PUBLIC_SUPABASE_URL=your_supabase_url
|
PUBLIC_SUPABASE_URL=your_supabase_url
|
||||||
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||||
# Service role key — required for admin operations (invite emails, etc.)
|
# Service role key — required for admin operations (invite emails, etc.)
|
||||||
# Find it in Supabase Dashboard → Settings → API → service_role key
|
# Find it in Supabase Dashboard → Settings → API → service_role key
|
||||||
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
|
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
|
||||||
|
|
||||||
|
# ── Google ──
|
||||||
GOOGLE_API_KEY=your_google_api_key
|
GOOGLE_API_KEY=your_google_api_key
|
||||||
# Google Service Account for Calendar push (create/update/delete events)
|
# Google Service Account for Calendar push (create/update/delete events)
|
||||||
# Paste the full JSON key file contents, or base64-encode it
|
# Paste the full JSON key file contents, or base64-encode it
|
||||||
# The calendar must be shared with the service account email (with "Make changes to events" permission)
|
# The calendar must be shared with the service account email (with "Make changes to events" permission)
|
||||||
GOOGLE_SERVICE_ACCOUNT_KEY=
|
GOOGLE_SERVICE_ACCOUNT_KEY=
|
||||||
|
|
||||||
# Matrix / Synapse integration
|
# ── Matrix / Synapse (optional — chat is not yet enabled) ──
|
||||||
# The homeserver URL where your Synapse instance is running
|
# The homeserver URL where your Synapse instance is running
|
||||||
MATRIX_HOMESERVER_URL=https://matrix.example.com
|
MATRIX_HOMESERVER_URL=https://matrix.example.com
|
||||||
# Synapse Admin API shared secret or admin access token
|
# Synapse Admin API shared secret or admin access token
|
||||||
# Used to auto-provision Matrix accounts for users
|
# Used to auto-provision Matrix accounts for users
|
||||||
MATRIX_ADMIN_TOKEN=
|
MATRIX_ADMIN_TOKEN=
|
||||||
|
|
||||||
|
# ── Docker / Production ──
|
||||||
|
# Public URL of the app — required by SvelteKit node adapter for CSRF protection
|
||||||
|
# Set this to your actual domain in production (e.g. https://app.example.com)
|
||||||
|
ORIGIN=http://localhost:3000
|
||||||
|
|||||||
38
Dockerfile
38
Dockerfile
@@ -1,43 +1,59 @@
|
|||||||
# Build stage
|
# ── Build stage ──
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Build args needed by $env/static/public at build time
|
||||||
|
ARG PUBLIC_SUPABASE_URL
|
||||||
|
ARG PUBLIC_SUPABASE_ANON_KEY
|
||||||
|
|
||||||
|
# Copy package files first for better layer caching
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install all dependencies (including dev)
|
# Install all dependencies (including dev) needed for the build
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the application
|
# Build the SvelteKit application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Prune dev dependencies
|
# ── Production dependencies stage ──
|
||||||
RUN npm prune --production
|
FROM node:22-alpine AS deps
|
||||||
|
|
||||||
# Production stage
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install only production dependencies
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# ── Runtime stage ──
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy built application
|
# Copy built application and production deps
|
||||||
COPY --from=builder /app/build build/
|
COPY --from=builder /app/build build/
|
||||||
COPY --from=builder /app/node_modules node_modules/
|
COPY --from=deps /app/node_modules node_modules/
|
||||||
COPY package.json .
|
COPY package.json .
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Set environment
|
# Set environment defaults
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
|
# SvelteKit node adapter needs ORIGIN for CSRF protection
|
||||||
|
# Override at runtime via docker-compose or -e flag
|
||||||
|
ENV ORIGIN=http://localhost:3000
|
||||||
|
# Allow file uploads up to 10 MB
|
||||||
|
ENV BODY_SIZE_LIMIT=10485760
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
|
|||||||
@@ -3,31 +3,26 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL}
|
||||||
|
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
# Supabase
|
# SvelteKit node adapter CSRF — set to your public URL in production
|
||||||
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL}
|
- ORIGIN=${ORIGIN:-http://localhost:3000}
|
||||||
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY}
|
- BODY_SIZE_LIMIT=10485760
|
||||||
# Google
|
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY}
|
|
||||||
- GOOGLE_SERVICE_ACCOUNT_KEY=${GOOGLE_SERVICE_ACCOUNT_KEY}
|
|
||||||
# Matrix
|
|
||||||
- MATRIX_HOMESERVER_URL=${MATRIX_HOMESERVER_URL}
|
|
||||||
- MATRIX_ADMIN_TOKEN=${MATRIX_ADMIN_TOKEN}
|
|
||||||
# Email (Resend)
|
|
||||||
- RESEND_API_KEY=${RESEND_API_KEY}
|
|
||||||
- RESEND_FROM_EMAIL=${RESEND_FROM_EMAIL}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
|
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health" ]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 5s
|
start_period: 10s
|
||||||
|
|
||||||
# Development mode with hot reload
|
# Development mode with hot reload
|
||||||
dev:
|
dev:
|
||||||
@@ -39,8 +34,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL}
|
|
||||||
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY}
|
|
||||||
command: npm run dev -- --host
|
command: npm run dev -- --host
|
||||||
|
|||||||
@@ -268,8 +268,6 @@
|
|||||||
{onEdit}
|
{onEdit}
|
||||||
{onDelete}
|
{onDelete}
|
||||||
{onReply}
|
{onReply}
|
||||||
{onLoadMore}
|
|
||||||
isLoading={isLoadingMore}
|
|
||||||
/>
|
/>
|
||||||
<TypingIndicator userNames={typingUsers} />
|
<TypingIndicator userNames={typingUsers} />
|
||||||
<MessageInput
|
<MessageInput
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar } from "$lib/components/ui";
|
import { MatrixAvatar } from "$lib/components/ui";
|
||||||
import UserProfileModal from "./UserProfileModal.svelte";
|
import UserProfileModal from "./UserProfileModal.svelte";
|
||||||
import type { RoomMember } from "$lib/matrix/types";
|
import type { RoomMember } from "$lib/matrix/types";
|
||||||
import { userPresence } from "$lib/stores/matrix";
|
import { userPresence } from "$lib/stores/matrix";
|
||||||
@@ -66,8 +66,8 @@
|
|||||||
class="w-full flex items-center gap-3 px-4 py-2 hover:bg-light/5 transition-colors text-left"
|
class="w-full flex items-center gap-3 px-4 py-2 hover:bg-light/5 transition-colors text-left"
|
||||||
onclick={() => handleMemberClick(member)}
|
onclick={() => handleMemberClick(member)}
|
||||||
>
|
>
|
||||||
<Avatar
|
<MatrixAvatar
|
||||||
src={member.avatarUrl}
|
mxcUrl={member.avatarUrl}
|
||||||
name={member.name}
|
name={member.name}
|
||||||
size="sm"
|
size="sm"
|
||||||
status={getPresenceStatus(member.userId)}
|
status={getPresenceStatus(member.userId)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar } from '$lib/components/ui';
|
import { MatrixAvatar } from "$lib/components/ui";
|
||||||
import type { RoomMember } from '$lib/matrix/types';
|
import type { RoomMember } from "$lib/matrix/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
members: RoomMember[];
|
members: RoomMember[];
|
||||||
@@ -23,11 +23,12 @@
|
|||||||
// Filter members based on query
|
// Filter members based on query
|
||||||
const filteredMembers = $derived(
|
const filteredMembers = $derived(
|
||||||
members
|
members
|
||||||
.filter(m =>
|
.filter(
|
||||||
m.name.toLowerCase().includes(query.toLowerCase()) ||
|
(m) =>
|
||||||
m.userId.toLowerCase().includes(query.toLowerCase())
|
m.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
m.userId.toLowerCase().includes(query.toLowerCase()),
|
||||||
)
|
)
|
||||||
.slice(0, 8)
|
.slice(0, 8),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset selection when query changes
|
// Reset selection when query changes
|
||||||
@@ -40,22 +41,23 @@
|
|||||||
if (filteredMembers.length === 0) return;
|
if (filteredMembers.length === 0) return;
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown':
|
case "ArrowDown":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectedIndex = (selectedIndex + 1) % filteredMembers.length;
|
selectedIndex = (selectedIndex + 1) % filteredMembers.length;
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case "ArrowUp":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectedIndex = (selectedIndex - 1 + filteredMembers.length) % filteredMembers.length;
|
selectedIndex =
|
||||||
|
(selectedIndex - 1 + filteredMembers.length) % filteredMembers.length;
|
||||||
break;
|
break;
|
||||||
case 'Enter':
|
case "Enter":
|
||||||
case 'Tab':
|
case "Tab":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (filteredMembers[selectedIndex]) {
|
if (filteredMembers[selectedIndex]) {
|
||||||
onSelect(filteredMembers[selectedIndex]);
|
onSelect(filteredMembers[selectedIndex]);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Escape':
|
case "Escape":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onClose();
|
onClose();
|
||||||
break;
|
break;
|
||||||
@@ -76,11 +78,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{#each filteredMembers as member, i}
|
{#each filteredMembers as member, i}
|
||||||
<button
|
<button
|
||||||
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors {i === selectedIndex ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors {i ===
|
||||||
|
selectedIndex
|
||||||
|
? 'bg-primary/20'
|
||||||
|
: 'hover:bg-light/5'}"
|
||||||
onclick={() => onSelect(member)}
|
onclick={() => onSelect(member)}
|
||||||
onmouseenter={() => selectedIndex = i}
|
onmouseenter={() => (selectedIndex = i)}
|
||||||
>
|
>
|
||||||
<Avatar src={member.avatarUrl} name={member.name} size="sm" />
|
<MatrixAvatar mxcUrl={member.avatarUrl} name={member.name} size="sm" />
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-light truncate">{member.name}</p>
|
<p class="text-light truncate">{member.name}</p>
|
||||||
<p class="text-xs text-light/40 truncate">{member.userId}</p>
|
<p class="text-xs text-light/40 truncate">{member.userId}</p>
|
||||||
|
|||||||
@@ -36,14 +36,12 @@
|
|||||||
|
|
||||||
// Render emojis as Twemoji images for preview
|
// Render emojis as Twemoji images for preview
|
||||||
function renderEmojiPreview(text: string): string {
|
function renderEmojiPreview(text: string): string {
|
||||||
// Escape HTML first
|
|
||||||
const escaped = text
|
const escaped = text
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/\n/g, "<br>");
|
.replace(/\n/g, "<br>");
|
||||||
|
|
||||||
// Replace emojis with Twemoji images
|
|
||||||
return escaped.replace(emojiRegex, (emoji) => {
|
return escaped.replace(emojiRegex, (emoji) => {
|
||||||
const url = getTwemojiUrl(emoji);
|
const url = getTwemojiUrl(emoji);
|
||||||
return `<img class="inline-block w-5 h-5 align-text-bottom" src="${url}" alt="${emoji}" draggable="false" />`;
|
return `<img class="inline-block w-5 h-5 align-text-bottom" src="${url}" alt="${emoji}" draggable="false" />`;
|
||||||
@@ -89,7 +87,6 @@
|
|||||||
|
|
||||||
// Emoji picker state
|
// Emoji picker state
|
||||||
let showEmojiPicker = $state(false);
|
let showEmojiPicker = $state(false);
|
||||||
let emojiButtonRef: HTMLButtonElement;
|
|
||||||
|
|
||||||
// Emoji autocomplete state
|
// Emoji autocomplete state
|
||||||
let showEmojiAutocomplete = $state(false);
|
let showEmojiAutocomplete = $state(false);
|
||||||
@@ -125,22 +122,17 @@
|
|||||||
function autoResize() {
|
function autoResize() {
|
||||||
if (!inputRef) return;
|
if (!inputRef) return;
|
||||||
inputRef.style.height = "auto";
|
inputRef.style.height = "auto";
|
||||||
inputRef.style.height = Math.min(inputRef.scrollHeight, 200) + "px";
|
inputRef.style.height = Math.min(inputRef.scrollHeight, 160) + "px";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle typing indicator
|
// Handle typing indicator
|
||||||
function handleTyping() {
|
function handleTyping() {
|
||||||
// Clear existing timeout
|
if (typingTimeout) clearTimeout(typingTimeout);
|
||||||
if (typingTimeout) {
|
|
||||||
clearTimeout(typingTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send typing indicator
|
|
||||||
setTyping(roomId, true).catch((e) =>
|
setTyping(roomId, true).catch((e) =>
|
||||||
log.error("Failed to send typing", { error: e }),
|
log.error("Failed to send typing", { error: e }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stop typing after 3 seconds of no input
|
|
||||||
typingTimeout = setTimeout(() => {
|
typingTimeout = setTimeout(() => {
|
||||||
setTyping(roomId, false).catch((e) =>
|
setTyping(roomId, false).catch((e) =>
|
||||||
log.error("Failed to stop typing", { error: e }),
|
log.error("Failed to stop typing", { error: e }),
|
||||||
@@ -151,31 +143,20 @@
|
|||||||
// Handle input
|
// Handle input
|
||||||
function handleInput() {
|
function handleInput() {
|
||||||
autoResize();
|
autoResize();
|
||||||
if (message.trim()) {
|
if (message.trim()) handleTyping();
|
||||||
handleTyping();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-convert completed emoji shortcodes like :heart: to actual emojis
|
|
||||||
autoConvertShortcodes();
|
autoConvertShortcodes();
|
||||||
|
|
||||||
// Check for @ mentions and : emoji shortcodes
|
|
||||||
checkForMention();
|
checkForMention();
|
||||||
checkForEmoji();
|
checkForEmoji();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-convert completed emoji shortcodes (e.g., :heart:) to actual emojis
|
// Auto-convert completed emoji shortcodes
|
||||||
function autoConvertShortcodes() {
|
function autoConvertShortcodes() {
|
||||||
if (!inputRef) return;
|
if (!inputRef) return;
|
||||||
const cursorPos = inputRef.selectionStart;
|
const cursorPos = inputRef.selectionStart;
|
||||||
|
|
||||||
// Look for completed shortcodes like :name:
|
|
||||||
const converted = convertEmojiShortcodes(message);
|
const converted = convertEmojiShortcodes(message);
|
||||||
if (converted !== message) {
|
if (converted !== message) {
|
||||||
// Calculate cursor offset based on length difference
|
|
||||||
const lengthDiff = message.length - converted.length;
|
const lengthDiff = message.length - converted.length;
|
||||||
message = converted;
|
message = converted;
|
||||||
|
|
||||||
// Restore cursor position (adjusted for shorter string)
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (inputRef) {
|
if (inputRef) {
|
||||||
const newPos = Math.max(0, cursorPos - lengthDiff);
|
const newPos = Math.max(0, cursorPos - lengthDiff);
|
||||||
@@ -188,16 +169,12 @@
|
|||||||
// Check if user is typing an emoji shortcode
|
// Check if user is typing an emoji shortcode
|
||||||
function checkForEmoji() {
|
function checkForEmoji() {
|
||||||
if (!inputRef) return;
|
if (!inputRef) return;
|
||||||
|
|
||||||
const cursorPos = inputRef.selectionStart;
|
const cursorPos = inputRef.selectionStart;
|
||||||
const textBeforeCursor = message.slice(0, cursorPos);
|
const textBeforeCursor = message.slice(0, cursorPos);
|
||||||
|
|
||||||
// Find the last : before cursor
|
|
||||||
const lastColonIndex = textBeforeCursor.lastIndexOf(":");
|
const lastColonIndex = textBeforeCursor.lastIndexOf(":");
|
||||||
|
|
||||||
if (lastColonIndex >= 0) {
|
if (lastColonIndex >= 0) {
|
||||||
const textAfterColon = textBeforeCursor.slice(lastColonIndex + 1);
|
const textAfterColon = textBeforeCursor.slice(lastColonIndex + 1);
|
||||||
// Check if there's a space before : (or it's at start) and no space after, and query is at least 2 chars
|
|
||||||
const charBeforeColon =
|
const charBeforeColon =
|
||||||
lastColonIndex > 0 ? message[lastColonIndex - 1] : " ";
|
lastColonIndex > 0 ? message[lastColonIndex - 1] : " ";
|
||||||
|
|
||||||
@@ -222,31 +199,23 @@
|
|||||||
|
|
||||||
// Handle emoji selection from autocomplete
|
// Handle emoji selection from autocomplete
|
||||||
function handleEmojiSelect(emoji: string) {
|
function handleEmojiSelect(emoji: string) {
|
||||||
// Replace :query with the emoji
|
|
||||||
const beforeEmoji = message.slice(0, emojiStartIndex);
|
const beforeEmoji = message.slice(0, emojiStartIndex);
|
||||||
const afterEmoji = message.slice(emojiStartIndex + emojiQuery.length + 1);
|
const afterEmoji = message.slice(emojiStartIndex + emojiQuery.length + 1);
|
||||||
message = `${beforeEmoji}${emoji}${afterEmoji}`;
|
message = `${beforeEmoji}${emoji}${afterEmoji}`;
|
||||||
|
|
||||||
showEmojiAutocomplete = false;
|
showEmojiAutocomplete = false;
|
||||||
emojiQuery = "";
|
emojiQuery = "";
|
||||||
|
|
||||||
// Focus back on textarea
|
|
||||||
inputRef?.focus();
|
inputRef?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is typing a mention
|
// Check if user is typing a mention
|
||||||
function checkForMention() {
|
function checkForMention() {
|
||||||
if (!inputRef) return;
|
if (!inputRef) return;
|
||||||
|
|
||||||
const cursorPos = inputRef.selectionStart;
|
const cursorPos = inputRef.selectionStart;
|
||||||
const textBeforeCursor = message.slice(0, cursorPos);
|
const textBeforeCursor = message.slice(0, cursorPos);
|
||||||
|
|
||||||
// Find the last @ before cursor that's not part of a completed mention
|
|
||||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
|
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
|
||||||
|
|
||||||
if (lastAtIndex >= 0) {
|
if (lastAtIndex >= 0) {
|
||||||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
||||||
// Check if there's a space before @ (or it's at start) and no space after
|
|
||||||
const charBeforeAt = lastAtIndex > 0 ? message[lastAtIndex - 1] : " ";
|
const charBeforeAt = lastAtIndex > 0 ? message[lastAtIndex - 1] : " ";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -266,23 +235,19 @@
|
|||||||
|
|
||||||
// Handle mention selection
|
// Handle mention selection
|
||||||
function handleMentionSelect(member: RoomMember) {
|
function handleMentionSelect(member: RoomMember) {
|
||||||
// Replace @query with userId (userId already has @ prefix)
|
|
||||||
const beforeMention = message.slice(0, mentionStartIndex);
|
const beforeMention = message.slice(0, mentionStartIndex);
|
||||||
const afterMention = message.slice(
|
const afterMention = message.slice(
|
||||||
mentionStartIndex + mentionQuery.length + 1,
|
mentionStartIndex + mentionQuery.length + 1,
|
||||||
);
|
);
|
||||||
message = `${beforeMention}${member.userId} ${afterMention}`;
|
message = `${beforeMention}${member.userId} ${afterMention}`;
|
||||||
|
|
||||||
showMentions = false;
|
showMentions = false;
|
||||||
mentionQuery = "";
|
mentionQuery = "";
|
||||||
|
|
||||||
// Focus back on textarea
|
|
||||||
inputRef?.focus();
|
inputRef?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle key press
|
// Handle key press
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
// If mention autocomplete is open, let it handle navigation keys
|
// Mention autocomplete navigation
|
||||||
if (
|
if (
|
||||||
showMentions &&
|
showMentions &&
|
||||||
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
|
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
|
||||||
@@ -290,15 +255,13 @@
|
|||||||
autocompleteRef?.handleKeyDown(e);
|
autocompleteRef?.handleKeyDown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter with mention autocomplete open selects the mention
|
|
||||||
if (showMentions && e.key === "Enter") {
|
if (showMentions && e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
autocompleteRef?.handleKeyDown(e);
|
autocompleteRef?.handleKeyDown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If emoji autocomplete is open, let it handle navigation keys
|
// Emoji autocomplete navigation
|
||||||
if (
|
if (
|
||||||
showEmojiAutocomplete &&
|
showEmojiAutocomplete &&
|
||||||
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
|
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
|
||||||
@@ -306,8 +269,6 @@
|
|||||||
emojiAutocompleteRef?.handleKeyDown(e);
|
emojiAutocompleteRef?.handleKeyDown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter with emoji autocomplete open selects the emoji
|
|
||||||
if (showEmojiAutocomplete && e.key === "Enter") {
|
if (showEmojiAutocomplete && e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
emojiAutocompleteRef?.handleKeyDown(e);
|
emojiAutocompleteRef?.handleKeyDown(e);
|
||||||
@@ -321,70 +282,42 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-continue lists on Shift+Enter or regular Enter with list
|
// Auto-continue lists on Shift+Enter
|
||||||
if (e.key === "Enter" && e.shiftKey) {
|
if (e.key === "Enter" && e.shiftKey) {
|
||||||
const cursorPos = inputRef?.selectionStart || 0;
|
const cursorPos = inputRef?.selectionStart || 0;
|
||||||
const textBefore = message.slice(0, cursorPos);
|
const textBefore = message.slice(0, cursorPos);
|
||||||
const currentLine = textBefore.split("\n").pop() || "";
|
const currentLine = textBefore.split("\n").pop() || "";
|
||||||
|
|
||||||
// Check for numbered list (1. 2. etc)
|
|
||||||
const numberedMatch = currentLine.match(/^(\s*)(\d+)\.\s/);
|
const numberedMatch = currentLine.match(/^(\s*)(\d+)\.\s/);
|
||||||
if (numberedMatch) {
|
if (numberedMatch) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const indent = numberedMatch[1];
|
const indent = numberedMatch[1];
|
||||||
const nextNum = parseInt(numberedMatch[2]) + 1;
|
const nextNum = parseInt(numberedMatch[2]) + 1;
|
||||||
const newText =
|
message =
|
||||||
message.slice(0, cursorPos) +
|
message.slice(0, cursorPos) +
|
||||||
`\n${indent}${nextNum}. ` +
|
`\n${indent}${nextNum}. ` +
|
||||||
message.slice(cursorPos);
|
message.slice(cursorPos);
|
||||||
message = newText;
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (inputRef) {
|
if (inputRef)
|
||||||
inputRef.selectionStart = inputRef.selectionEnd =
|
inputRef.selectionStart = inputRef.selectionEnd =
|
||||||
cursorPos + indent.length + String(nextNum).length + 4;
|
cursorPos + indent.length + String(nextNum).length + 4;
|
||||||
}
|
|
||||||
}, 0);
|
}, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for bullet list (- or *)
|
|
||||||
const bulletMatch = currentLine.match(/^(\s*)([-*])\s/);
|
const bulletMatch = currentLine.match(/^(\s*)([-*])\s/);
|
||||||
if (bulletMatch) {
|
if (bulletMatch) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const indent = bulletMatch[1];
|
const indent = bulletMatch[1];
|
||||||
const bullet = bulletMatch[2];
|
const bullet = bulletMatch[2];
|
||||||
const newText =
|
message =
|
||||||
message.slice(0, cursorPos) +
|
message.slice(0, cursorPos) +
|
||||||
`\n${indent}${bullet} ` +
|
`\n${indent}${bullet} ` +
|
||||||
message.slice(cursorPos);
|
message.slice(cursorPos);
|
||||||
message = newText;
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (inputRef) {
|
if (inputRef)
|
||||||
inputRef.selectionStart = inputRef.selectionEnd =
|
inputRef.selectionStart = inputRef.selectionEnd =
|
||||||
cursorPos + indent.length + 4;
|
cursorPos + indent.length + 4;
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for lettered sub-list (a. b. etc)
|
|
||||||
const letteredMatch = currentLine.match(/^(\s*)([a-z])\.\s/);
|
|
||||||
if (letteredMatch) {
|
|
||||||
e.preventDefault();
|
|
||||||
const indent = letteredMatch[1];
|
|
||||||
const nextLetter = String.fromCharCode(
|
|
||||||
letteredMatch[2].charCodeAt(0) + 1,
|
|
||||||
);
|
|
||||||
const newText =
|
|
||||||
message.slice(0, cursorPos) +
|
|
||||||
`\n${indent}${nextLetter}. ` +
|
|
||||||
message.slice(cursorPos);
|
|
||||||
message = newText;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (inputRef) {
|
|
||||||
inputRef.selectionStart = inputRef.selectionEnd =
|
|
||||||
cursorPos + indent.length + 5;
|
|
||||||
}
|
|
||||||
}, 0);
|
}, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -396,28 +329,23 @@
|
|||||||
const trimmed = message.trim();
|
const trimmed = message.trim();
|
||||||
if (!trimmed || isSending || disabled) return;
|
if (!trimmed || isSending || disabled) return;
|
||||||
|
|
||||||
// Convert emoji shortcodes like :heart: to actual emojis
|
|
||||||
const processedMessage = convertEmojiShortcodes(trimmed);
|
const processedMessage = convertEmojiShortcodes(trimmed);
|
||||||
|
|
||||||
// Handle edit mode
|
// Handle edit mode
|
||||||
if (editingMessage) {
|
if (editingMessage) {
|
||||||
if (processedMessage === editingMessage.content) {
|
if (processedMessage === editingMessage.content) {
|
||||||
// No changes, just cancel
|
|
||||||
onCancelEdit?.();
|
onCancelEdit?.();
|
||||||
message = "";
|
message = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSaveEdit?.(processedMessage);
|
onSaveEdit?.(processedMessage);
|
||||||
message = "";
|
message = "";
|
||||||
if (inputRef) {
|
if (inputRef) inputRef.style.height = "auto";
|
||||||
inputRef.style.height = "auto";
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSending = true;
|
isSending = true;
|
||||||
|
|
||||||
// Clear typing indicator
|
|
||||||
if (typingTimeout) {
|
if (typingTimeout) {
|
||||||
clearTimeout(typingTimeout);
|
clearTimeout(typingTimeout);
|
||||||
typingTimeout = null;
|
typingTimeout = null;
|
||||||
@@ -426,10 +354,8 @@
|
|||||||
log.error("Failed to stop typing", { error: e }),
|
log.error("Failed to stop typing", { error: e }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a temporary event ID for the pending message
|
|
||||||
const tempEventId = `pending-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
const tempEventId = `pending-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
// Add pending message immediately (optimistic update)
|
|
||||||
const pendingMessage: Message = {
|
const pendingMessage: Message = {
|
||||||
eventId: tempEventId,
|
eventId: tempEventId,
|
||||||
roomId,
|
roomId,
|
||||||
@@ -448,14 +374,9 @@
|
|||||||
|
|
||||||
addPendingMessage(roomId, pendingMessage);
|
addPendingMessage(roomId, pendingMessage);
|
||||||
message = "";
|
message = "";
|
||||||
|
|
||||||
// Clear reply
|
|
||||||
onCancelReply?.();
|
onCancelReply?.();
|
||||||
|
|
||||||
// Reset textarea height
|
if (inputRef) inputRef.style.height = "auto";
|
||||||
if (inputRef) {
|
|
||||||
inputRef.style.height = "auto";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sendMessage(
|
const result = await sendMessage(
|
||||||
@@ -463,21 +384,17 @@
|
|||||||
processedMessage,
|
processedMessage,
|
||||||
replyTo?.eventId,
|
replyTo?.eventId,
|
||||||
);
|
);
|
||||||
// Confirm the pending message with the real event ID
|
|
||||||
if (result?.event_id) {
|
if (result?.event_id) {
|
||||||
confirmPendingMessage(roomId, tempEventId, result.event_id);
|
confirmPendingMessage(roomId, tempEventId, result.event_id);
|
||||||
} else {
|
} else {
|
||||||
// If no event ID returned, just mark as not pending
|
|
||||||
confirmPendingMessage(roomId, tempEventId, tempEventId);
|
confirmPendingMessage(roomId, tempEventId, tempEventId);
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
log.error("Failed to send message", { error: e });
|
log.error("Failed to send message", { error: e });
|
||||||
// Remove the pending message on failure
|
|
||||||
removePendingMessage(roomId, tempEventId);
|
removePendingMessage(roomId, tempEventId);
|
||||||
toasts.error(getErrorMessage(e, "Failed to send message"));
|
toasts.error(getErrorMessage(e, "Failed to send message"));
|
||||||
} finally {
|
} finally {
|
||||||
isSending = false;
|
isSending = false;
|
||||||
// Refocus after DOM settles from optimistic update
|
|
||||||
await tick();
|
await tick();
|
||||||
inputRef?.focus();
|
inputRef?.focus();
|
||||||
}
|
}
|
||||||
@@ -488,11 +405,8 @@
|
|||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file || disabled) return;
|
if (!file || disabled) return;
|
||||||
|
|
||||||
// Reset input
|
|
||||||
input.value = "";
|
input.value = "";
|
||||||
|
|
||||||
// Check file size (50MB limit)
|
|
||||||
const maxSize = 50 * 1024 * 1024;
|
const maxSize = 50 * 1024 * 1024;
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
toasts.error(m.toast_error_file_too_large());
|
toasts.error(m.toast_error_file_too_large());
|
||||||
@@ -518,35 +432,34 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="border-t border-light/10">
|
<div class="border-t border-light/5">
|
||||||
<!-- Edit preview -->
|
<!-- Edit preview -->
|
||||||
{#if editingMessage}
|
{#if editingMessage}
|
||||||
<div class="px-4 pt-3 pb-0">
|
<div class="px-4 pt-3 pb-0">
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-yellow-500/10 rounded-lg border-l-2 border-yellow-500"
|
class="flex items-center gap-2 px-3 py-2 bg-yellow-500/10 rounded-lg border-l-2 border-yellow-500"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-yellow-400"
|
||||||
|
style="font-size: 16px;">edit</span
|
||||||
|
>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-xs text-yellow-400 font-medium">Editing message</p>
|
<p class="text-[11px] text-yellow-400 font-body">Editing message</p>
|
||||||
<p class="text-sm text-light/60 truncate">{editingMessage.content}</p>
|
<p class="text-[12px] text-light/50 truncate">
|
||||||
|
{editingMessage.content}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light rounded transition-colors"
|
class="w-6 h-6 flex items-center justify-center text-light/30 hover:text-white rounded transition-colors"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
onCancelEdit?.();
|
onCancelEdit?.();
|
||||||
message = "";
|
message = "";
|
||||||
}}
|
}}
|
||||||
title="Cancel edit"
|
title="Cancel edit"
|
||||||
>
|
>
|
||||||
<svg
|
<span class="material-symbols-rounded" style="font-size: 16px;"
|
||||||
class="w-4 h-4"
|
>close</span
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -556,35 +469,47 @@
|
|||||||
{#if replyTo && !editingMessage}
|
{#if replyTo && !editingMessage}
|
||||||
<div class="px-4 pt-3 pb-0">
|
<div class="px-4 pt-3 pb-0">
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-light/5 rounded-lg border-l-2 border-primary"
|
class="flex items-center gap-2 px-3 py-2 bg-primary/5 rounded-lg border-l-2 border-primary"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-primary"
|
||||||
|
style="font-size: 16px;">reply</span
|
||||||
|
>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-xs text-primary font-medium">
|
<p class="text-[11px] text-primary font-body">
|
||||||
Replying to {replyTo.senderName}
|
Replying to {replyTo.senderName}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-light/60 truncate">{replyTo.content}</p>
|
<p class="text-[12px] text-light/50 truncate">{replyTo.content}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light rounded transition-colors"
|
class="w-6 h-6 flex items-center justify-center text-light/30 hover:text-white rounded transition-colors"
|
||||||
onclick={() => onCancelReply?.()}
|
onclick={() => onCancelReply?.()}
|
||||||
title="Cancel reply"
|
title="Cancel reply"
|
||||||
>
|
>
|
||||||
<svg
|
<span class="material-symbols-rounded" style="font-size: 16px;"
|
||||||
class="w-4 h-4"
|
>close</span
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="p-4 flex items-end gap-3">
|
<!-- Emoji Picker (above input) -->
|
||||||
|
{#if showEmojiPicker}
|
||||||
|
<div class="flex justify-end px-4 pb-2">
|
||||||
|
<div class="relative">
|
||||||
|
<EmojiPicker
|
||||||
|
onSelect={(emoji) => {
|
||||||
|
message += emoji;
|
||||||
|
inputRef?.focus();
|
||||||
|
}}
|
||||||
|
onClose={() => (showEmojiPicker = false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="px-4 py-3 flex items-end gap-2">
|
||||||
<!-- Hidden file input -->
|
<!-- Hidden file input -->
|
||||||
<input
|
<input
|
||||||
bind:this={fileInputRef}
|
bind:this={fileInputRef}
|
||||||
@@ -596,40 +521,20 @@
|
|||||||
|
|
||||||
<!-- Attachment button -->
|
<!-- Attachment button -->
|
||||||
<button
|
<button
|
||||||
class="w-10 h-10 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-full transition-colors shrink-0"
|
class="w-8 h-8 flex items-center justify-center text-light/30 hover:text-white hover:bg-light/5 rounded-lg transition-colors shrink-0"
|
||||||
class:animate-pulse={isUploading}
|
class:animate-pulse={isUploading}
|
||||||
title="Add attachment"
|
title="Add attachment"
|
||||||
onclick={openFilePicker}
|
onclick={openFilePicker}
|
||||||
disabled={disabled || isUploading}
|
disabled={disabled || isUploading}
|
||||||
>
|
>
|
||||||
{#if isUploading}
|
{#if isUploading}
|
||||||
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
<div
|
||||||
<circle
|
class="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin"
|
||||||
class="opacity-25"
|
></div>
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
{:else}
|
||||||
<svg
|
<span class="material-symbols-rounded" style="font-size: 20px;"
|
||||||
class="w-5 h-5"
|
>attach_file</span
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" y1="8" x2="12" y2="16" />
|
|
||||||
<line x1="8" y1="12" x2="16" y2="12" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -656,13 +561,13 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Input wrapper with emoji button inside -->
|
<!-- Input wrapper -->
|
||||||
<div class="relative flex items-end">
|
<div class="relative flex items-end">
|
||||||
<!-- Emoji preview overlay - shows rendered Twemoji -->
|
<!-- Emoji preview overlay -->
|
||||||
{#if message && hasEmoji(message)}
|
{#if message && hasEmoji(message)}
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 pl-4 pr-12 py-3 pointer-events-none overflow-hidden rounded-2xl text-light whitespace-pre-wrap break-words"
|
class="absolute inset-0 pl-3 pr-10 py-2.5 pointer-events-none overflow-hidden rounded-xl text-light text-[13px] whitespace-pre-wrap break-words"
|
||||||
style="min-height: 48px; max-height: 200px; line-height: 1.5;"
|
style="min-height: 40px; max-height: 160px; line-height: 1.5;"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{@html renderEmojiPreview(message)}
|
{@html renderEmojiPreview(message)}
|
||||||
@@ -676,96 +581,63 @@
|
|||||||
{placeholder}
|
{placeholder}
|
||||||
disabled={disabled || isSending}
|
disabled={disabled || isSending}
|
||||||
rows="1"
|
rows="1"
|
||||||
class="w-full pl-4 pr-12 py-3 bg-dark rounded-2xl border border-light/20
|
class="w-full pl-3 pr-10 py-2.5 bg-dark/50 rounded-xl border border-light/5 text-[13px]
|
||||||
placeholder:text-light/40 resize-none overflow-hidden
|
placeholder:text-light/25 resize-none overflow-hidden
|
||||||
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
|
focus:outline-none focus:border-light/15
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
transition-colors {message && hasEmoji(message)
|
transition-colors {message && hasEmoji(message)
|
||||||
? 'text-transparent caret-light'
|
? 'text-transparent caret-light'
|
||||||
: 'text-light'}"
|
: 'text-light'}"
|
||||||
style="min-height: 48px; max-height: 200px;"
|
style="min-height: 40px; max-height: 160px;"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<!-- Emoji button inside input -->
|
<!-- Emoji button inside input -->
|
||||||
<button
|
<button
|
||||||
bind:this={emojiButtonRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute right-3 bottom-3 w-6 h-6 flex items-center justify-center text-light/40 hover:text-light transition-colors"
|
class="absolute right-2.5 bottom-2 w-6 h-6 flex items-center justify-center text-light/25 hover:text-light/60 transition-colors"
|
||||||
onclick={() => (showEmojiPicker = !showEmojiPicker)}
|
onclick={() => (showEmojiPicker = !showEmojiPicker)}
|
||||||
title="Add emoji"
|
title="Add emoji"
|
||||||
>
|
>
|
||||||
<svg
|
<span class="material-symbols-rounded" style="font-size: 18px;"
|
||||||
class="w-5 h-5"
|
>sentiment_satisfied</span
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
|
||||||
<line x1="9" y1="9" x2="9.01" y2="9" />
|
|
||||||
<line x1="15" y1="9" x2="15.01" y2="9" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Emoji Picker -->
|
|
||||||
{#if showEmojiPicker}
|
|
||||||
<div class="absolute bottom-full right-0 mb-2">
|
|
||||||
<EmojiPicker
|
|
||||||
onSelect={(emoji) => {
|
|
||||||
message += emoji;
|
|
||||||
inputRef?.focus();
|
|
||||||
}}
|
|
||||||
onClose={() => (showEmojiPicker = false)}
|
|
||||||
position={{ x: 0, y: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Send button -->
|
<!-- Send button -->
|
||||||
<button
|
<button
|
||||||
class="w-10 h-10 flex items-center justify-center rounded-full transition-all shrink-0
|
class="w-8 h-8 flex items-center justify-center rounded-lg transition-all shrink-0
|
||||||
{message.trim()
|
{message.trim()
|
||||||
? 'bg-primary text-white hover:brightness-110'
|
? 'bg-primary text-white hover:brightness-110'
|
||||||
: 'bg-light/10 text-light/30 cursor-not-allowed'}"
|
: 'text-light/20 cursor-not-allowed'}"
|
||||||
onclick={handleSend}
|
onclick={handleSend}
|
||||||
disabled={!message.trim() || isSending || disabled}
|
disabled={!message.trim() || isSending || disabled}
|
||||||
title="Send message"
|
title="Send message"
|
||||||
>
|
>
|
||||||
{#if isSending}
|
{#if isSending}
|
||||||
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
<div
|
||||||
<circle
|
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
|
||||||
class="opacity-25"
|
></div>
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
{:else}
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
<span
|
||||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
class="material-symbols-rounded"
|
||||||
</svg>
|
style="font-size: 20px; font-variation-settings: 'FILL' 1;">send</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Character count (optional, show when > 1000) -->
|
<!-- Character count -->
|
||||||
{#if message.length > 1000}
|
{#if message.length > 1000}
|
||||||
<div
|
<div class="px-4 pb-2 text-right">
|
||||||
class="text-right text-xs mt-1 {message.length > 4000
|
<span
|
||||||
? 'text-red-400'
|
class="text-[10px] {message.length > 4000
|
||||||
: 'text-light/40'}"
|
? 'text-red-400'
|
||||||
>
|
: 'text-light/25'}"
|
||||||
{message.length} / 4000
|
>
|
||||||
|
{message.length} / 4000
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick, untrack } from "svelte";
|
import { onMount, tick } from "svelte";
|
||||||
import { createVirtualizer, elementScroll } from "@tanstack/svelte-virtual";
|
|
||||||
import type { SvelteVirtualizer } from "@tanstack/svelte-virtual";
|
|
||||||
import { MessageContainer } from "$lib/components/message";
|
import { MessageContainer } from "$lib/components/message";
|
||||||
import type { Message as MessageType } from "$lib/matrix/types";
|
import type { Message as MessageType } from "$lib/matrix/types";
|
||||||
import { auth } from "$lib/stores/matrix";
|
import { auth } from "$lib/stores/matrix";
|
||||||
@@ -17,9 +15,6 @@
|
|||||||
onEdit?: (message: MessageType) => void;
|
onEdit?: (message: MessageType) => void;
|
||||||
onDelete?: (messageId: string) => void;
|
onDelete?: (messageId: string) => void;
|
||||||
onReply?: (message: MessageType) => void;
|
onReply?: (message: MessageType) => void;
|
||||||
onLoadMore?: () => void;
|
|
||||||
isLoading?: boolean;
|
|
||||||
enableVirtualization?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -29,175 +24,23 @@
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onReply,
|
onReply,
|
||||||
onLoadMore,
|
|
||||||
isLoading = false,
|
|
||||||
enableVirtualization = false, // Disabled until we find a Svelte 5-compatible solution
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let containerRef: HTMLDivElement | undefined = $state();
|
let containerRef: HTMLDivElement | undefined = $state();
|
||||||
let shouldAutoScroll = $state(true);
|
let shouldAutoScroll = $state(true);
|
||||||
let previousMessageCount = $state(0);
|
let previousMessageCount = $state(0);
|
||||||
|
|
||||||
// Filter out deleted/redacted messages (hide them like Discord)
|
// Filter out deleted/redacted messages
|
||||||
const allVisibleMessages = $derived(messages.filter((m) => !m.isRedacted));
|
const allVisibleMessages = $derived(messages.filter((m) => !m.isRedacted));
|
||||||
|
|
||||||
// Virtualizer state - managed via subscription
|
// Track scroll position to decide auto-scroll
|
||||||
let virtualizer = $state<SvelteVirtualizer<HTMLDivElement, Element> | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
let virtualizerCleanup: (() => void) | null = null;
|
|
||||||
|
|
||||||
// Estimate size based on message type
|
|
||||||
function estimateSize(index: number): number {
|
|
||||||
const msg = allVisibleMessages[index];
|
|
||||||
if (!msg) return 80;
|
|
||||||
if (msg.type === "image") return 300;
|
|
||||||
if (msg.type === "video") return 350;
|
|
||||||
if (msg.type === "file" || msg.type === "audio") return 100;
|
|
||||||
const lines = Math.ceil((msg.content?.length || 0) / 60);
|
|
||||||
return Math.max(60, Math.min(lines * 24 + 40, 400));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create/update virtualizer when container or messages change
|
|
||||||
$effect(() => {
|
|
||||||
if (
|
|
||||||
!containerRef ||
|
|
||||||
!enableVirtualization ||
|
|
||||||
allVisibleMessages.length === 0
|
|
||||||
) {
|
|
||||||
virtualizer = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up previous subscription
|
|
||||||
if (virtualizerCleanup) {
|
|
||||||
virtualizerCleanup();
|
|
||||||
virtualizerCleanup = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new virtualizer store
|
|
||||||
const store = createVirtualizer({
|
|
||||||
count: allVisibleMessages.length,
|
|
||||||
getScrollElement: () => containerRef!,
|
|
||||||
estimateSize,
|
|
||||||
overscan: 5,
|
|
||||||
getItemKey: (index) => allVisibleMessages[index]?.eventId ?? index,
|
|
||||||
scrollToFn: elementScroll,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subscribe to store updates
|
|
||||||
virtualizerCleanup = store.subscribe((v) => {
|
|
||||||
virtualizer = v;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup on effect re-run or component destroy
|
|
||||||
return () => {
|
|
||||||
if (virtualizerCleanup) {
|
|
||||||
virtualizerCleanup();
|
|
||||||
virtualizerCleanup = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get virtual items for rendering (reactive to virtualizer changes)
|
|
||||||
const virtualItems = $derived(virtualizer?.getVirtualItems() ?? []);
|
|
||||||
const totalSize = $derived(virtualizer?.getTotalSize() ?? 0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Svelte action for dynamic height measurement
|
|
||||||
* Re-measures when images/media finish loading
|
|
||||||
*/
|
|
||||||
function measureRow(node: HTMLElement, index: number) {
|
|
||||||
function measure() {
|
|
||||||
if (virtualizer) {
|
|
||||||
virtualizer.measureElement(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial measurement
|
|
||||||
measure();
|
|
||||||
|
|
||||||
// Re-measure when images load
|
|
||||||
const images = node.querySelectorAll("img");
|
|
||||||
const imageHandlers: Array<() => void> = [];
|
|
||||||
images.forEach((img) => {
|
|
||||||
if (!img.complete) {
|
|
||||||
const handler = () => measure();
|
|
||||||
img.addEventListener("load", handler, { once: true });
|
|
||||||
img.addEventListener("error", handler, { once: true });
|
|
||||||
imageHandlers.push(() => {
|
|
||||||
img.removeEventListener("load", handler);
|
|
||||||
img.removeEventListener("error", handler);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-measure when videos load metadata
|
|
||||||
const videos = node.querySelectorAll("video");
|
|
||||||
const videoHandlers: Array<() => void> = [];
|
|
||||||
videos.forEach((video) => {
|
|
||||||
if (video.readyState < 1) {
|
|
||||||
const handler = () => measure();
|
|
||||||
video.addEventListener("loadedmetadata", handler, { once: true });
|
|
||||||
videoHandlers.push(() =>
|
|
||||||
video.removeEventListener("loadedmetadata", handler),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
update(newIndex: number) {
|
|
||||||
// Re-measure on update
|
|
||||||
measure();
|
|
||||||
},
|
|
||||||
destroy() {
|
|
||||||
// Cleanup listeners
|
|
||||||
imageHandlers.forEach((cleanup) => cleanup());
|
|
||||||
videoHandlers.forEach((cleanup) => cleanup());
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track if we're currently loading to prevent scroll jumps
|
|
||||||
let isLoadingMore = $state(false);
|
|
||||||
let scrollTopBeforeLoad = $state(0);
|
|
||||||
let scrollHeightBeforeLoad = $state(0);
|
|
||||||
|
|
||||||
// Check if we should auto-scroll and load more
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
if (!containerRef) return;
|
if (!containerRef) return;
|
||||||
const { scrollTop, scrollHeight, clientHeight } = containerRef;
|
const { scrollTop, scrollHeight, clientHeight } = containerRef;
|
||||||
|
|
||||||
// Check if at bottom for auto-scroll
|
|
||||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
||||||
shouldAutoScroll = distanceToBottom < 100;
|
shouldAutoScroll = distanceToBottom < 100;
|
||||||
|
|
||||||
// Check if at top to load more messages (with debounce via isLoadingMore)
|
|
||||||
if (scrollTop < 100 && onLoadMore && !isLoading && !isLoadingMore) {
|
|
||||||
// Save scroll position before loading
|
|
||||||
isLoadingMore = true;
|
|
||||||
scrollTopBeforeLoad = scrollTop;
|
|
||||||
scrollHeightBeforeLoad = scrollHeight;
|
|
||||||
onLoadMore();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore scroll position after loading older messages
|
|
||||||
$effect(() => {
|
|
||||||
if (!isLoading && isLoadingMore && containerRef) {
|
|
||||||
// Loading finished - restore scroll position
|
|
||||||
tick().then(() => {
|
|
||||||
if (containerRef) {
|
|
||||||
const newScrollHeight = containerRef.scrollHeight;
|
|
||||||
const addedHeight = newScrollHeight - scrollHeightBeforeLoad;
|
|
||||||
// Adjust scroll to maintain visual position
|
|
||||||
containerRef.scrollTop = scrollTopBeforeLoad + addedHeight;
|
|
||||||
}
|
|
||||||
isLoadingMore = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll to bottom
|
// Scroll to bottom
|
||||||
async function scrollToBottom(force = false) {
|
async function scrollToBottom(force = false) {
|
||||||
if (!containerRef) return;
|
if (!containerRef) return;
|
||||||
@@ -207,25 +50,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll when new messages arrive (only if at bottom)
|
// Auto-scroll when new messages arrive
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const count = allVisibleMessages.length;
|
const count = allVisibleMessages.length;
|
||||||
|
|
||||||
if (count > previousMessageCount) {
|
if (count > previousMessageCount) {
|
||||||
if (shouldAutoScroll || previousMessageCount === 0) {
|
if (shouldAutoScroll || previousMessageCount === 0) {
|
||||||
// User is at bottom or first load - scroll to new messages
|
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
}
|
}
|
||||||
// If user is scrolled up, scroll anchoring handles it
|
|
||||||
}
|
}
|
||||||
previousMessageCount = count;
|
previousMessageCount = count;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial scroll to bottom
|
// Initial scroll to bottom
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
tick().then(() => {
|
tick().then(() => scrollToBottom(true));
|
||||||
scrollToBottom(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if message should be grouped with previous
|
// Check if message should be grouped with previous
|
||||||
@@ -235,8 +73,6 @@
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (!previous) return false;
|
if (!previous) return false;
|
||||||
if (current.sender !== previous.sender) return false;
|
if (current.sender !== previous.sender) return false;
|
||||||
|
|
||||||
// Group if within 5 minutes
|
|
||||||
const timeDiff = current.timestamp - previous.timestamp;
|
const timeDiff = current.timestamp - previous.timestamp;
|
||||||
return timeDiff < 5 * 60 * 1000;
|
return timeDiff < 5 * 60 * 1000;
|
||||||
}
|
}
|
||||||
@@ -247,10 +83,8 @@
|
|||||||
previous: MessageType | null,
|
previous: MessageType | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!previous) return true;
|
if (!previous) return true;
|
||||||
|
|
||||||
const currentDate = new Date(current.timestamp).toDateString();
|
const currentDate = new Date(current.timestamp).toDateString();
|
||||||
const previousDate = new Date(previous.timestamp).toDateString();
|
const previousDate = new Date(previous.timestamp).toDateString();
|
||||||
|
|
||||||
return currentDate !== previousDate;
|
return currentDate !== previousDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,9 +148,8 @@
|
|||||||
const element = document.getElementById(`message-${eventId}`);
|
const element = document.getElementById(`message-${eventId}`);
|
||||||
if (element) {
|
if (element) {
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
// Highlight briefly
|
element.classList.add("bg-primary/10");
|
||||||
element.classList.add("bg-primary/20");
|
setTimeout(() => element.classList.remove("bg-primary/10"), 2000);
|
||||||
setTimeout(() => element.classList.remove("bg-primary/20"), 2000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -324,97 +157,23 @@
|
|||||||
<div class="relative flex-1 min-h-0">
|
<div class="relative flex-1 min-h-0">
|
||||||
<div
|
<div
|
||||||
bind:this={containerRef}
|
bind:this={containerRef}
|
||||||
class="h-full overflow-y-auto bg-night"
|
class="h-full overflow-y-auto scrollbar-thin"
|
||||||
onscroll={handleScroll}
|
onscroll={handleScroll}
|
||||||
>
|
>
|
||||||
<!-- Load more button -->
|
|
||||||
{#if onLoadMore}
|
|
||||||
<div class="flex justify-center py-4">
|
|
||||||
<button
|
|
||||||
class="text-sm text-primary hover:underline disabled:opacity-50"
|
|
||||||
onclick={() => onLoadMore?.()}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? "Loading..." : "Load older messages"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Messages -->
|
|
||||||
{#if allVisibleMessages.length === 0}
|
{#if allVisibleMessages.length === 0}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center h-full text-light/40"
|
class="flex flex-col items-center justify-center h-full text-light/30"
|
||||||
>
|
>
|
||||||
<svg
|
<span
|
||||||
class="w-16 h-16 mb-4 opacity-50"
|
class="material-symbols-rounded mb-3"
|
||||||
viewBox="0 0 24 24"
|
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 200, 'GRAD' 0, 'opsz' 48;"
|
||||||
fill="none"
|
>forum</span
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
>
|
||||||
<path
|
<p class="text-body-sm text-light/40 mb-1">No messages yet</p>
|
||||||
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
|
<p class="text-[12px] text-light/20">Be the first to send a message!</p>
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-lg">No messages yet</p>
|
|
||||||
<p class="text-sm">Be the first to send a message!</p>
|
|
||||||
</div>
|
|
||||||
{:else if virtualizer && enableVirtualization}
|
|
||||||
<!-- TanStack Virtual: True DOM recycling -->
|
|
||||||
<div class="relative w-full" style="height: {totalSize}px;">
|
|
||||||
{#each virtualItems as virtualRow (virtualRow.key)}
|
|
||||||
{@const message = allVisibleMessages[virtualRow.index]}
|
|
||||||
{@const previousMessage =
|
|
||||||
virtualRow.index > 0
|
|
||||||
? allVisibleMessages[virtualRow.index - 1]
|
|
||||||
: null}
|
|
||||||
{@const isGrouped = shouldGroup(message, previousMessage)}
|
|
||||||
{@const showDateSeparator = needsDateSeparator(
|
|
||||||
message,
|
|
||||||
previousMessage,
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="absolute top-0 left-0 w-full"
|
|
||||||
style="transform: translateY({virtualRow.start}px);"
|
|
||||||
data-index={virtualRow.index}
|
|
||||||
use:measureRow={virtualRow.index}
|
|
||||||
>
|
|
||||||
<!-- Date separator -->
|
|
||||||
{#if showDateSeparator}
|
|
||||||
<div class="flex items-center gap-4 px-4 py-2 my-2">
|
|
||||||
<div class="flex-1 h-px bg-light/10"></div>
|
|
||||||
<span class="text-xs text-light/40 font-medium">
|
|
||||||
{formatDateSeparator(message.timestamp)}
|
|
||||||
</span>
|
|
||||||
<div class="flex-1 h-px bg-light/10"></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<MessageContainer
|
|
||||||
{message}
|
|
||||||
{isGrouped}
|
|
||||||
isOwnMessage={message.sender === $auth.userId}
|
|
||||||
currentUserId={$auth.userId || ""}
|
|
||||||
onReact={(emoji: string) => onReact?.(message.eventId, emoji)}
|
|
||||||
onToggleReaction={(
|
|
||||||
emoji: string,
|
|
||||||
reactionEventId: string | null,
|
|
||||||
) => onToggleReaction?.(message.eventId, emoji, reactionEventId)}
|
|
||||||
onEdit={() => onEdit?.(message)}
|
|
||||||
onDelete={() => onDelete?.(message.eventId)}
|
|
||||||
onReply={() => onReply?.(message)}
|
|
||||||
onScrollToMessage={scrollToMessage}
|
|
||||||
replyPreview={message.replyTo
|
|
||||||
? getReplyPreview(message.replyTo)
|
|
||||||
: null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Fallback: Non-virtualized rendering for small lists -->
|
<div class="py-2">
|
||||||
<div class="py-4">
|
|
||||||
{#each allVisibleMessages as message, i (message.eventId)}
|
{#each allVisibleMessages as message, i (message.eventId)}
|
||||||
{@const previousMessage = i > 0 ? allVisibleMessages[i - 1] : null}
|
{@const previousMessage = i > 0 ? allVisibleMessages[i - 1] : null}
|
||||||
{@const isGrouped = shouldGroup(message, previousMessage)}
|
{@const isGrouped = shouldGroup(message, previousMessage)}
|
||||||
@@ -425,12 +184,12 @@
|
|||||||
|
|
||||||
<!-- Date separator -->
|
<!-- Date separator -->
|
||||||
{#if showDateSeparator}
|
{#if showDateSeparator}
|
||||||
<div class="flex items-center gap-4 px-4 py-2 my-2">
|
<div class="flex items-center gap-3 px-5 py-3">
|
||||||
<div class="flex-1 h-px bg-light/10"></div>
|
<div class="flex-1 h-px bg-light/5"></div>
|
||||||
<span class="text-xs text-light/40 font-medium">
|
<span class="text-[11px] text-light/30 font-body select-none">
|
||||||
{formatDateSeparator(message.timestamp)}
|
{formatDateSeparator(message.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1 h-px bg-light/10"></div>
|
<div class="flex-1 h-px bg-light/5"></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -455,24 +214,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scroll to bottom button -->
|
<!-- Scroll to bottom FAB -->
|
||||||
{#if !shouldAutoScroll && allVisibleMessages.length > 0}
|
{#if !shouldAutoScroll && allVisibleMessages.length > 0}
|
||||||
<button
|
<button
|
||||||
class="absolute bottom-4 right-4 p-3 bg-primary text-white rounded-full shadow-lg
|
class="absolute bottom-3 right-3 w-9 h-9 flex items-center justify-center
|
||||||
hover:bg-primary/90 transition-all transform hover:scale-105
|
bg-dark/80 backdrop-blur-sm border border-light/10 text-light/60
|
||||||
animate-in fade-in slide-in-from-bottom-2 duration-200"
|
rounded-full shadow-lg hover:text-white hover:border-light/20
|
||||||
|
transition-all"
|
||||||
onclick={() => scrollToBottom(true)}
|
onclick={() => scrollToBottom(true)}
|
||||||
title="Scroll to bottom"
|
title="Scroll to bottom"
|
||||||
>
|
>
|
||||||
<svg
|
<span class="material-symbols-rounded" style="font-size: 20px;"
|
||||||
class="w-5 h-5"
|
>keyboard_arrow_down</span
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<polyline points="6,9 12,15 18,9" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar } from "$lib/components/ui";
|
import { MatrixAvatar } from "$lib/components/ui";
|
||||||
import RoomSettingsModal from "./RoomSettingsModal.svelte";
|
import RoomSettingsModal from "./RoomSettingsModal.svelte";
|
||||||
import {
|
import {
|
||||||
getRoomNotificationLevel,
|
getRoomNotificationLevel,
|
||||||
@@ -52,141 +52,104 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-full flex flex-col bg-dark/50">
|
<div class="h-full flex flex-col">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="p-4 border-b border-light/10 flex items-center justify-between">
|
<div
|
||||||
<h2 class="font-semibold text-light">Room Info</h2>
|
class="px-4 py-3 border-b border-light/5 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<h2 class="font-heading text-[13px] text-white">Room Info</h2>
|
||||||
<button
|
<button
|
||||||
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
|
class="p-1 text-light/30 hover:text-white hover:bg-light/5 rounded-lg transition-colors"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
title="Close"
|
title="Close"
|
||||||
>
|
>
|
||||||
<svg
|
<span class="material-symbols-rounded" style="font-size: 18px;"
|
||||||
class="w-5 h-5"
|
>close</span
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex-1 overflow-y-auto p-4 space-y-6">
|
<div class="flex-1 overflow-y-auto scrollbar-thin p-4 space-y-5">
|
||||||
<!-- Room Avatar & Name -->
|
<!-- Room Avatar & Name -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="flex justify-center mb-3">
|
<div class="flex justify-center mb-3">
|
||||||
<Avatar src={room.avatarUrl} name={room.name} size="xl" />
|
<MatrixAvatar mxcUrl={room.avatarUrl} name={room.name} size="xl" />
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-light">{room.name}</h3>
|
<h3 class="text-[15px] font-heading text-white">{room.name}</h3>
|
||||||
{#if room.topic}
|
{#if room.topic}
|
||||||
<p class="text-sm text-light/60 mt-2">{room.topic}</p>
|
<p class="text-[12px] text-light/40 mt-1.5">{room.topic}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<div class="flex items-center justify-center gap-2 mt-3">
|
||||||
class="mt-3 px-4 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
<button
|
||||||
onclick={() => (showSettings = true)}
|
class="px-3 py-1.5 text-[12px] text-light/50 hover:text-white hover:bg-light/5 rounded-lg transition-colors inline-flex items-center gap-1.5"
|
||||||
>
|
onclick={() => (showSettings = true)}
|
||||||
<span class="inline-flex items-center gap-1">
|
>
|
||||||
<svg
|
<span class="material-symbols-rounded" style="font-size: 16px;"
|
||||||
class="w-4 h-4"
|
>settings</span
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="3" />
|
Settings
|
||||||
<path
|
</button>
|
||||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
|
<button
|
||||||
/>
|
class="px-3 py-1.5 text-[12px] rounded-lg transition-colors inline-flex items-center gap-1.5
|
||||||
</svg>
|
{isMuted
|
||||||
Edit Settings
|
? 'bg-red-500/10 text-red-400 hover:bg-red-500/20'
|
||||||
</span>
|
: 'text-light/50 hover:text-white hover:bg-light/5'}"
|
||||||
</button>
|
onclick={toggleMute}
|
||||||
<button
|
disabled={isTogglingMute}
|
||||||
class="mt-2 px-4 py-1.5 text-sm rounded-lg transition-colors {isMuted
|
>
|
||||||
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
|
<span class="material-symbols-rounded" style="font-size: 16px;">
|
||||||
: 'text-light/60 hover:text-light hover:bg-light/10'}"
|
{isMuted ? "notifications_off" : "notifications"}
|
||||||
onclick={toggleMute}
|
</span>
|
||||||
disabled={isTogglingMute}
|
{isMuted ? "Muted" : "Notifications"}
|
||||||
>
|
</button>
|
||||||
<span class="inline-flex items-center gap-1">
|
</div>
|
||||||
{#if isMuted}
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
|
||||||
<line x1="23" y1="9" x2="17" y2="15" />
|
|
||||||
<line x1="17" y1="9" x2="23" y2="15" />
|
|
||||||
</svg>
|
|
||||||
Muted
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
||||||
<path
|
|
||||||
d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Notifications On
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Room Stats -->
|
<!-- Room Stats -->
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div class="bg-night rounded-lg p-3 text-center">
|
<div class="bg-dark/30 border border-light/5 rounded-lg p-3 text-center">
|
||||||
<p class="text-2xl font-bold text-light">{room.memberCount}</p>
|
<p class="text-[18px] font-heading text-white">{room.memberCount}</p>
|
||||||
<p class="text-xs text-light/50">Members</p>
|
<p class="text-[10px] text-light/30">Members</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-night rounded-lg p-3 text-center">
|
<div class="bg-dark/30 border border-light/5 rounded-lg p-3 text-center">
|
||||||
<p class="text-2xl font-bold text-light">
|
<span
|
||||||
{room.isEncrypted ? "🔒" : "🔓"}
|
class="material-symbols-rounded text-light/40"
|
||||||
</p>
|
style="font-size: 20px;"
|
||||||
<p class="text-xs text-light/50">
|
>
|
||||||
|
{room.isEncrypted ? "lock" : "lock_open"}
|
||||||
|
</span>
|
||||||
|
<p class="text-[10px] text-light/30 mt-0.5">
|
||||||
{room.isEncrypted ? "Encrypted" : "Not Encrypted"}
|
{room.isEncrypted ? "Encrypted" : "Not Encrypted"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Room Details -->
|
<!-- Room Details -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-2">
|
||||||
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
|
<h4 class="text-[10px] font-body text-light/25 uppercase tracking-wider">
|
||||||
Details
|
Details
|
||||||
</h4>
|
</h4>
|
||||||
|
<div class="space-y-1.5 text-[12px]">
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-light/50">Room ID</span>
|
<span class="text-light/35">Room ID</span>
|
||||||
<span
|
<span
|
||||||
class="text-light font-mono text-xs truncate max-w-[150px]"
|
class="text-light/60 font-mono text-[10px] truncate max-w-[140px]"
|
||||||
title={room.roomId}
|
title={room.roomId}
|
||||||
>
|
>
|
||||||
{room.roomId}
|
{room.roomId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-light/50">Type</span>
|
<span class="text-light/35">Type</span>
|
||||||
<span class="text-light"
|
<span class="text-light/60"
|
||||||
>{room.isDirect ? "Direct Message" : "Room"}</span
|
>{room.isDirect ? "Direct Message" : "Room"}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if room.lastActivity}
|
{#if room.lastActivity}
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-light/50">Last Activity</span>
|
<span class="text-light/35">Last Activity</span>
|
||||||
<span class="text-light">{formatDate(room.lastActivity)}</span>
|
<span class="text-light/60">{formatDate(room.lastActivity)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -194,20 +157,29 @@
|
|||||||
|
|
||||||
<!-- Members by Role -->
|
<!-- Members by Role -->
|
||||||
{#if admins.length > 0}
|
{#if admins.length > 0}
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<h4
|
<h4
|
||||||
class="text-sm font-semibold text-light/40 uppercase tracking-wider"
|
class="text-[10px] font-body text-light/25 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Admins ({admins.length})
|
Admins ({admins.length})
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-0.5">
|
||||||
{#each admins as member}
|
{#each admins as member}
|
||||||
<li
|
<li
|
||||||
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
|
class="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-light/5 transition-colors"
|
||||||
>
|
>
|
||||||
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
|
<MatrixAvatar
|
||||||
<span class="text-sm text-light truncate">{member.name}</span>
|
mxcUrl={member.avatarUrl}
|
||||||
<span class="ml-auto text-xs text-yellow-400">👑</span>
|
name={member.name}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<span class="text-[12px] text-light/70 truncate"
|
||||||
|
>{member.name}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded ml-auto text-yellow-400"
|
||||||
|
style="font-size: 14px;">shield_person</span
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -215,41 +187,55 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if moderators.length > 0}
|
{#if moderators.length > 0}
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<h4
|
<h4
|
||||||
class="text-sm font-semibold text-light/40 uppercase tracking-wider"
|
class="text-[10px] font-body text-light/25 uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Moderators ({moderators.length})
|
Moderators ({moderators.length})
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-0.5">
|
||||||
{#each moderators as member}
|
{#each moderators as member}
|
||||||
<li
|
<li
|
||||||
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
|
class="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-light/5 transition-colors"
|
||||||
>
|
>
|
||||||
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
|
<MatrixAvatar
|
||||||
<span class="text-sm text-light truncate">{member.name}</span>
|
mxcUrl={member.avatarUrl}
|
||||||
<span class="ml-auto text-xs text-blue-400">🛡️</span>
|
name={member.name}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<span class="text-[12px] text-light/70 truncate"
|
||||||
|
>{member.name}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded ml-auto text-blue-400"
|
||||||
|
style="font-size: 14px;">shield</span
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
|
<h4 class="text-[10px] font-body text-light/25 uppercase tracking-wider">
|
||||||
Members ({regularMembers.length})
|
Members ({regularMembers.length})
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-0.5">
|
||||||
{#each regularMembers.slice(0, 20) as member}
|
{#each regularMembers.slice(0, 20) as member}
|
||||||
<li
|
<li
|
||||||
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
|
class="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-light/5 transition-colors"
|
||||||
>
|
>
|
||||||
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
|
<MatrixAvatar
|
||||||
<span class="text-sm text-light truncate">{member.name}</span>
|
mxcUrl={member.avatarUrl}
|
||||||
|
name={member.name}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<span class="text-[12px] text-light/70 truncate">{member.name}</span
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
{#if regularMembers.length > 20}
|
{#if regularMembers.length > 20}
|
||||||
<li class="text-xs text-light/40 text-center py-2">
|
<li class="text-[11px] text-light/25 text-center py-2">
|
||||||
+{regularMembers.length - 20} more members
|
+{regularMembers.length - 20} more members
|
||||||
</li>
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar } from "$lib/components/ui";
|
import { MatrixAvatar } from "$lib/components/ui";
|
||||||
import { setRoomName, setRoomTopic, setRoomAvatar } from "$lib/matrix";
|
import { setRoomName, setRoomTopic, setRoomAvatar } from "$lib/matrix";
|
||||||
import { toasts } from "$lib/stores/ui";
|
import { toasts } from "$lib/stores/ui";
|
||||||
import * as m from "$lib/paraglide/messages";
|
import * as m from "$lib/paraglide/messages";
|
||||||
@@ -112,7 +112,11 @@
|
|||||||
<!-- Avatar -->
|
<!-- Avatar -->
|
||||||
<div class="flex flex-col items-center mb-6">
|
<div class="flex flex-col items-center mb-6">
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<Avatar src={avatarPreview || room.avatarUrl} {name} size="xl" />
|
<MatrixAvatar
|
||||||
|
mxcUrl={avatarPreview || room.avatarUrl}
|
||||||
|
{name}
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
<label
|
<label
|
||||||
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 rounded-full cursor-pointer transition-opacity"
|
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 rounded-full cursor-pointer transition-opacity"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar } from '$lib/components/ui';
|
import { MatrixAvatar } from "$lib/components/ui";
|
||||||
import { searchUsers, createDirectMessage } from '$lib/matrix';
|
import { searchUsers, createDirectMessage } from "$lib/matrix";
|
||||||
import { toasts } from '$lib/stores/ui';
|
import { toasts } from "$lib/stores/ui";
|
||||||
import { createLogger, getErrorMessage } from '$lib/utils/logger';
|
import { createLogger, getErrorMessage } from "$lib/utils/logger";
|
||||||
|
|
||||||
const log = createLogger('matrix:dm');
|
const log = createLogger("matrix:dm");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -13,15 +13,17 @@
|
|||||||
|
|
||||||
let { onClose, onDMCreated }: Props = $props();
|
let { onClose, onDMCreated }: Props = $props();
|
||||||
|
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state("");
|
||||||
let searchResults = $state<Array<{ userId: string; displayName: string; avatarUrl: string | null }>>([]);
|
let searchResults = $state<
|
||||||
|
Array<{ userId: string; displayName: string; avatarUrl: string | null }>
|
||||||
|
>([]);
|
||||||
let isSearching = $state(false);
|
let isSearching = $state(false);
|
||||||
let isCreating = $state(false);
|
let isCreating = $state(false);
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
if (searchTimeout) clearTimeout(searchTimeout);
|
if (searchTimeout) clearTimeout(searchTimeout);
|
||||||
|
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
searchResults = [];
|
searchResults = [];
|
||||||
return;
|
return;
|
||||||
@@ -32,7 +34,7 @@
|
|||||||
try {
|
try {
|
||||||
searchResults = await searchUsers(searchQuery);
|
searchResults = await searchUsers(searchQuery);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('Search failed', { error: e });
|
log.error("Search failed", { error: e });
|
||||||
} finally {
|
} finally {
|
||||||
isSearching = false;
|
isSearching = false;
|
||||||
}
|
}
|
||||||
@@ -43,19 +45,19 @@
|
|||||||
isCreating = true;
|
isCreating = true;
|
||||||
try {
|
try {
|
||||||
const roomId = await createDirectMessage(userId);
|
const roomId = await createDirectMessage(userId);
|
||||||
toasts.success('Direct message started!');
|
toasts.success("Direct message started!");
|
||||||
onDMCreated(roomId);
|
onDMCreated(roomId);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
log.error('Failed to create DM', { error: e });
|
log.error("Failed to create DM", { error: e });
|
||||||
toasts.error(getErrorMessage(e, 'Failed to start direct message'));
|
toasts.error(getErrorMessage(e, "Failed to start direct message"));
|
||||||
} finally {
|
} finally {
|
||||||
isCreating = false;
|
isCreating = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +84,13 @@
|
|||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<path d="m21 21-4.35-4.35" />
|
<path d="m21 21-4.35-4.35" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -101,9 +109,24 @@
|
|||||||
<div class="max-h-64 overflow-y-auto">
|
<div class="max-h-64 overflow-y-auto">
|
||||||
{#if isSearching}
|
{#if isSearching}
|
||||||
<div class="text-center py-8 text-light/40">
|
<div class="text-center py-8 text-light/40">
|
||||||
<svg class="w-6 h-6 animate-spin mx-auto mb-2" viewBox="0 0 24 24" fill="none">
|
<svg
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
class="w-6 h-6 animate-spin mx-auto mb-2"
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
Searching...
|
Searching...
|
||||||
</div>
|
</div>
|
||||||
@@ -116,9 +139,15 @@
|
|||||||
onclick={() => handleStartDM(user.userId)}
|
onclick={() => handleStartDM(user.userId)}
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
>
|
>
|
||||||
<Avatar src={user.avatarUrl} name={user.displayName} size="sm" />
|
<MatrixAvatar
|
||||||
|
mxcUrl={user.avatarUrl}
|
||||||
|
name={user.displayName}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-light font-medium truncate">{user.displayName}</p>
|
<p class="text-light font-medium truncate">
|
||||||
|
{user.displayName}
|
||||||
|
</p>
|
||||||
<p class="text-xs text-light/40 truncate">{user.userId}</p>
|
<p class="text-xs text-light/40 truncate">{user.userId}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,21 +6,30 @@
|
|||||||
let { userNames }: Props = $props();
|
let { userNames }: Props = $props();
|
||||||
|
|
||||||
function formatTypingText(names: string[]): string {
|
function formatTypingText(names: string[]): string {
|
||||||
if (names.length === 0) return '';
|
if (names.length === 0) return "";
|
||||||
if (names.length === 1) return `${names[0]} is typing`;
|
if (names.length === 1) return `${names[0]} is typing`;
|
||||||
if (names.length === 2) return `${names[0]} and ${names[1]} are typing`;
|
if (names.length === 2) return `${names[0]} and ${names[1]} are typing`;
|
||||||
if (names.length === 3) return `${names[0]}, ${names[1]}, and ${names[2]} are typing`;
|
if (names.length === 3)
|
||||||
|
return `${names[0]}, ${names[1]}, and ${names[2]} are typing`;
|
||||||
return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing`;
|
return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if userNames.length > 0}
|
{#if userNames.length > 0}
|
||||||
<div class="flex items-center gap-2 px-4 py-2 text-sm text-light/50">
|
<div class="flex items-center gap-1.5 px-5 py-1.5 text-[11px] text-light/35">
|
||||||
<!-- Animated dots -->
|
<div class="flex gap-0.5">
|
||||||
<div class="flex gap-1">
|
<span
|
||||||
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 0ms"></span>
|
class="w-1.5 h-1.5 bg-light/35 rounded-full animate-bounce"
|
||||||
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 150ms"></span>
|
style="animation-delay: 0ms"
|
||||||
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 300ms"></span>
|
></span>
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 bg-light/35 rounded-full animate-bounce"
|
||||||
|
style="animation-delay: 150ms"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 bg-light/35 rounded-full animate-bounce"
|
||||||
|
style="animation-delay: 300ms"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<span>{formatTypingText(userNames)}</span>
|
<span>{formatTypingText(userNames)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar } from '$lib/components/ui';
|
import { MatrixAvatar } from "$lib/components/ui";
|
||||||
import { createDirectMessage } from '$lib/matrix';
|
import { createDirectMessage } from "$lib/matrix";
|
||||||
import { userPresence } from '$lib/stores/matrix';
|
import { userPresence } from "$lib/stores/matrix";
|
||||||
import { toasts } from '$lib/stores/ui';
|
import { toasts } from "$lib/stores/ui";
|
||||||
import { createLogger } from '$lib/utils/logger';
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
const log = createLogger('matrix:profile');
|
const log = createLogger("matrix:profile");
|
||||||
import type { RoomMember } from '$lib/matrix/types';
|
import type { RoomMember } from "$lib/matrix/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
member: RoomMember;
|
member: RoomMember;
|
||||||
@@ -19,16 +19,18 @@
|
|||||||
let isStartingDM = $state(false);
|
let isStartingDM = $state(false);
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === "Escape") onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
const presence = $derived($userPresence.get(member.userId) || 'offline');
|
const presence = $derived($userPresence.get(member.userId) || "offline");
|
||||||
|
|
||||||
const presenceLabel = $derived({
|
const presenceLabel = $derived(
|
||||||
online: { text: 'Online', color: 'text-green-400' },
|
{
|
||||||
offline: { text: 'Offline', color: 'text-gray-400' },
|
online: { text: "Online", color: "text-green-400" },
|
||||||
unavailable: { text: 'Away', color: 'text-yellow-400' },
|
offline: { text: "Offline", color: "text-gray-400" },
|
||||||
}[presence]);
|
unavailable: { text: "Away", color: "text-yellow-400" },
|
||||||
|
}[presence],
|
||||||
|
);
|
||||||
|
|
||||||
async function handleStartDM() {
|
async function handleStartDM() {
|
||||||
isStartingDM = true;
|
isStartingDM = true;
|
||||||
@@ -38,16 +40,28 @@
|
|||||||
onStartDM?.(roomId);
|
onStartDM?.(roomId);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('Failed to start DM', { error: e });
|
log.error("Failed to start DM", { error: e });
|
||||||
toasts.error('Failed to start direct message');
|
toasts.error("Failed to start direct message");
|
||||||
} finally {
|
} finally {
|
||||||
isStartingDM = false;
|
isStartingDM = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRoleBadge(powerLevel: number): { label: string; color: string; icon: string } | null {
|
function getRoleBadge(
|
||||||
if (powerLevel >= 100) return { label: 'Admin', color: 'bg-red-500/20 text-red-400', icon: '👑' };
|
powerLevel: number,
|
||||||
if (powerLevel >= 50) return { label: 'Moderator', color: 'bg-blue-500/20 text-blue-400', icon: '🛡️' };
|
): { label: string; color: string; icon: string } | null {
|
||||||
|
if (powerLevel >= 100)
|
||||||
|
return {
|
||||||
|
label: "Admin",
|
||||||
|
color: "bg-red-500/20 text-red-400",
|
||||||
|
icon: "👑",
|
||||||
|
};
|
||||||
|
if (powerLevel >= 50)
|
||||||
|
return {
|
||||||
|
label: "Moderator",
|
||||||
|
color: "bg-blue-500/20 text-blue-400",
|
||||||
|
icon: "🛡️",
|
||||||
|
};
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +77,7 @@
|
|||||||
aria-labelledby="profile-title"
|
aria-labelledby="profile-title"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
onkeydown={(e) => e.key === 'Enter' && onClose()}
|
onkeydown={(e) => e.key === "Enter" && onClose()}
|
||||||
>
|
>
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<div
|
<div
|
||||||
@@ -79,7 +93,13 @@
|
|||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
title="Close"
|
title="Close"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -89,26 +109,46 @@
|
|||||||
<!-- Avatar -->
|
<!-- Avatar -->
|
||||||
<div class="flex justify-center -mt-12 relative z-10">
|
<div class="flex justify-center -mt-12 relative z-10">
|
||||||
<div class="ring-4 ring-dark rounded-full">
|
<div class="ring-4 ring-dark rounded-full">
|
||||||
<Avatar src={member.avatarUrl} name={member.name} size="xl" status={presence === 'online' ? 'online' : presence === 'unavailable' ? 'away' : 'offline'} />
|
<MatrixAvatar
|
||||||
|
mxcUrl={member.avatarUrl}
|
||||||
|
name={member.name}
|
||||||
|
size="xl"
|
||||||
|
status={presence === "online"
|
||||||
|
? "online"
|
||||||
|
: presence === "unavailable"
|
||||||
|
? "away"
|
||||||
|
: "offline"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="p-6 pt-3 text-center">
|
<div class="p-6 pt-3 text-center">
|
||||||
<h2 id="profile-title" class="text-xl font-bold text-light">{member.name}</h2>
|
<h2 id="profile-title" class="text-xl font-bold text-light">
|
||||||
|
{member.name}
|
||||||
|
</h2>
|
||||||
<p class="text-sm text-light/50 mt-1">{member.userId}</p>
|
<p class="text-sm text-light/50 mt-1">{member.userId}</p>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<div class="flex items-center justify-center gap-2 mt-3">
|
<div class="flex items-center justify-center gap-2 mt-3">
|
||||||
<span class="w-2 h-2 rounded-full {presence === 'online' ? 'bg-green-400' : presence === 'unavailable' ? 'bg-yellow-400' : 'bg-gray-400'}"></span>
|
<span
|
||||||
|
class="w-2 h-2 rounded-full {presence === 'online'
|
||||||
|
? 'bg-green-400'
|
||||||
|
: presence === 'unavailable'
|
||||||
|
? 'bg-yellow-400'
|
||||||
|
: 'bg-gray-400'}"
|
||||||
|
></span>
|
||||||
<span class="text-sm {presenceLabel.color}">{presenceLabel.text}</span>
|
<span class="text-sm {presenceLabel.color}">{presenceLabel.text}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Role badge -->
|
<!-- Role badge -->
|
||||||
{#if roleBadge}
|
{#if roleBadge}
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs {roleBadge.color}">
|
<span
|
||||||
{roleBadge.icon} {roleBadge.label}
|
class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs {roleBadge.color}"
|
||||||
|
>
|
||||||
|
{roleBadge.icon}
|
||||||
|
{roleBadge.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -120,10 +160,18 @@
|
|||||||
onclick={handleStartDM}
|
onclick={handleStartDM}
|
||||||
disabled={isStartingDM}
|
disabled={isStartingDM}
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
class="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{isStartingDM ? 'Starting...' : 'Send Message'}
|
{isStartingDM ? "Starting..." : "Send Message"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar } from "$lib/components/ui";
|
import { MatrixAvatar } from "$lib/components/ui";
|
||||||
import { getReadReceiptsForEvent } from "$lib/matrix";
|
import { getReadReceiptsForEvent } from "$lib/matrix";
|
||||||
import type { Message } from "$lib/matrix/types";
|
import type { Message } from "$lib/matrix/types";
|
||||||
import { formatTime } from "./utils";
|
import { formatTime } from "./utils";
|
||||||
@@ -66,8 +66,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="group relative px-4 py-0.5 hover:bg-light/5 transition-colors {message.isPending
|
class="group relative px-5 py-0.5 hover:bg-light/[0.02] transition-colors {message.isPending
|
||||||
? 'opacity-50'
|
? 'opacity-40'
|
||||||
: ''}"
|
: ''}"
|
||||||
onmouseenter={() => (showActions = true)}
|
onmouseenter={() => (showActions = true)}
|
||||||
onmouseleave={() => (showActions = false)}
|
onmouseleave={() => (showActions = false)}
|
||||||
@@ -77,32 +77,22 @@
|
|||||||
<!-- Reply preview -->
|
<!-- Reply preview -->
|
||||||
{#if replyPreview && message.replyTo}
|
{#if replyPreview && message.replyTo}
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 ml-14 mt-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
|
class="flex items-center gap-1.5 ml-12 mt-1 mb-0.5 text-[11px] hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
onclick={() => onScrollToMessage?.(message.replyTo!)}
|
onclick={() => onScrollToMessage?.(message.replyTo!)}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="w-0.5 h-3 bg-primary/40 rounded-full shrink-0"></div>
|
||||||
<div class="flex shrink-0">
|
<MatrixAvatar
|
||||||
<Avatar
|
mxcUrl={replyPreview.senderAvatar}
|
||||||
src={replyPreview.senderAvatar}
|
name={replyPreview.senderName}
|
||||||
name={replyPreview.senderName}
|
size="xs"
|
||||||
size="xs"
|
/>
|
||||||
/>
|
<span class="text-light/50 font-body">{replyPreview.senderName}</span>
|
||||||
</div>
|
<span class="text-light/30 truncate max-w-xs">
|
||||||
<span class="text-light/70 font-medium">{replyPreview.senderName}</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-light/50 truncate max-w-xs">
|
|
||||||
{#if replyPreview.hasAttachment}
|
{#if replyPreview.hasAttachment}
|
||||||
<svg
|
<span
|
||||||
class="w-3 h-3 inline mr-0.5"
|
class="material-symbols-rounded align-middle mr-0.5"
|
||||||
viewBox="0 0 24 24"
|
style="font-size: 12px;">image</span
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
||||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
||||||
<polyline points="21,15 16,10 5,21" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
{/if}
|
||||||
{replyPreview.content}
|
{replyPreview.content}
|
||||||
</span>
|
</span>
|
||||||
@@ -110,11 +100,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isGrouped}
|
{#if isGrouped}
|
||||||
<!-- Grouped message (same sender, close in time) -->
|
<!-- Grouped message -->
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-3">
|
||||||
<div class="w-10 shrink-0 flex items-center justify-center">
|
<div class="w-9 shrink-0 flex items-center justify-center">
|
||||||
<span
|
<span
|
||||||
class="text-[10px] text-light/30 opacity-0 group-hover:opacity-100 transition-opacity"
|
class="text-[10px] text-light/20 opacity-0 group-hover:opacity-100 transition-opacity select-none"
|
||||||
>
|
>
|
||||||
{formatTime(message.timestamp)}
|
{formatTime(message.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
@@ -136,21 +126,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Full message with avatar - mt-4 creates gap between message groups -->
|
<!-- Full message with avatar -->
|
||||||
<div class="flex gap-4 mt-4 first:mt-0">
|
<div class="flex gap-3 mt-3 first:mt-0">
|
||||||
<div class="w-10 shrink-0">
|
<div class="w-9 shrink-0 pt-0.5">
|
||||||
<Avatar
|
<MatrixAvatar
|
||||||
src={message.senderAvatar}
|
mxcUrl={message.senderAvatar}
|
||||||
name={message.senderName}
|
name={message.senderName}
|
||||||
size="md"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-baseline gap-2 mb-0.5">
|
<div class="flex items-baseline gap-2 mb-px">
|
||||||
<span class="font-semibold text-light hover:underline cursor-pointer">
|
<span
|
||||||
|
class="text-[13px] font-heading text-white hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
{message.senderName}
|
{message.senderName}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-light/40">
|
<span class="text-[10px] text-light/25 select-none">
|
||||||
{formatTime(message.timestamp)}
|
{formatTime(message.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Twemoji from '$lib/components/ui/Twemoji.svelte';
|
import Twemoji from "$lib/components/ui/Twemoji.svelte";
|
||||||
import EmojiPicker from '$lib/components/ui/EmojiPicker.svelte';
|
import EmojiPicker from "$lib/components/ui/EmojiPicker.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOwnMessage?: boolean;
|
isOwnMessage?: boolean;
|
||||||
@@ -26,171 +26,152 @@
|
|||||||
onPin,
|
onPin,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const quickReactions = ['👍', '❤️', '😂'];
|
const quickReactions = ["👍", "❤️", "😂"];
|
||||||
|
|
||||||
let showEmojiPicker = $state(false);
|
let showEmojiPicker = $state(false);
|
||||||
let showContextMenu = $state(false);
|
let showContextMenu = $state(false);
|
||||||
let menuPosition = $state({ x: 0, y: 0 });
|
let menuRef: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
function openContextMenu(e: MouseEvent) {
|
function openContextMenu(e: MouseEvent) {
|
||||||
const button = e.currentTarget as HTMLElement;
|
|
||||||
const rect = button.getBoundingClientRect();
|
|
||||||
const menuHeight = 200;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
let y = rect.bottom + 4;
|
|
||||||
if (y + menuHeight > viewportHeight) {
|
|
||||||
y = rect.top - menuHeight - 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
menuPosition = { x: rect.right - 180, y: Math.max(8, y) };
|
|
||||||
showContextMenu = !showContextMenu;
|
showContextMenu = !showContextMenu;
|
||||||
showEmojiPicker = false;
|
showEmojiPicker = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEmojiPicker(e: MouseEvent) {
|
function openEmojiPicker(e: MouseEvent) {
|
||||||
const button = e.currentTarget as HTMLElement;
|
|
||||||
const rect = button.getBoundingClientRect();
|
|
||||||
const menuHeight = 150;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
let y = rect.bottom + 4;
|
|
||||||
if (y + menuHeight > viewportHeight) {
|
|
||||||
y = rect.top - menuHeight - 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
menuPosition = { x: rect.right - 220, y: Math.max(8, y) };
|
|
||||||
showEmojiPicker = !showEmojiPicker;
|
showEmojiPicker = !showEmojiPicker;
|
||||||
showContextMenu = false;
|
showContextMenu = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute right-4 -top-3 flex items-center gap-0.5 bg-dark border border-light/20 rounded-lg shadow-lg p-0.5">
|
<div
|
||||||
|
class="absolute right-3 -top-3 flex items-center gap-px bg-dark/90 backdrop-blur-sm border border-light/10 rounded-lg shadow-lg p-0.5 z-10"
|
||||||
|
>
|
||||||
<!-- Quick reactions -->
|
<!-- Quick reactions -->
|
||||||
{#each quickReactions as emoji}
|
{#each quickReactions as emoji}
|
||||||
<button
|
<button
|
||||||
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors"
|
class="w-7 h-7 flex items-center justify-center hover:bg-light/10 rounded transition-colors"
|
||||||
onclick={() => onReact?.(emoji)}
|
onclick={() => onReact?.(emoji)}
|
||||||
title="React with {emoji}"
|
title="React with {emoji}"
|
||||||
>
|
>
|
||||||
<Twemoji {emoji} size={18} />
|
<Twemoji {emoji} size={16} />
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Emoji picker -->
|
<!-- Emoji picker trigger -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
|
class="w-7 h-7 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/40 hover:text-light"
|
||||||
onclick={openEmojiPicker}
|
onclick={openEmojiPicker}
|
||||||
title="Add reaction"
|
title="Add reaction"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="material-symbols-rounded" style="font-size: 16px;"
|
||||||
<circle cx="12" cy="12" r="10" />
|
>add_reaction</span
|
||||||
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
>
|
||||||
<line x1="9" y1="9" x2="9.01" y2="9" />
|
|
||||||
<line x1="15" y1="9" x2="15.01" y2="9" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if showEmojiPicker}
|
{#if showEmojiPicker}
|
||||||
<EmojiPicker
|
<div class="absolute bottom-full right-0 mb-2 z-50">
|
||||||
position={menuPosition}
|
<EmojiPicker
|
||||||
onSelect={(emoji) => onReact?.(emoji)}
|
onSelect={(emoji) => {
|
||||||
onClose={() => (showEmojiPicker = false)}
|
onReact?.(emoji);
|
||||||
/>
|
showEmojiPicker = false;
|
||||||
|
}}
|
||||||
|
onClose={() => (showEmojiPicker = false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-px h-6 bg-light/20 mx-0.5"></div>
|
<div class="w-px h-5 bg-light/10 mx-0.5"></div>
|
||||||
|
|
||||||
<!-- Reply button -->
|
<!-- Reply -->
|
||||||
<button
|
<button
|
||||||
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
|
class="w-7 h-7 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/40 hover:text-light"
|
||||||
onclick={() => onReply?.()}
|
onclick={() => onReply?.()}
|
||||||
title="Reply"
|
title="Reply"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="material-symbols-rounded" style="font-size: 16px;">reply</span>
|
||||||
<polyline points="9,17 4,12 9,7" />
|
|
||||||
<path d="M20,18 v-2 a4,4 0 0,0 -4,-4 H4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Edit button (own messages only) -->
|
<!-- Edit (own messages only) -->
|
||||||
{#if isOwnMessage}
|
{#if isOwnMessage}
|
||||||
<button
|
<button
|
||||||
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
|
class="w-7 h-7 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/40 hover:text-light"
|
||||||
onclick={() => onEdit?.()}
|
onclick={() => onEdit?.()}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="material-symbols-rounded" style="font-size: 16px;">edit</span
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
>
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Context menu -->
|
<!-- More options -->
|
||||||
<div class="relative">
|
<div class="relative" bind:this={menuRef}>
|
||||||
<button
|
<button
|
||||||
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
|
class="w-7 h-7 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/40 hover:text-light"
|
||||||
onclick={openContextMenu}
|
onclick={openContextMenu}
|
||||||
title="More options"
|
title="More"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="material-symbols-rounded" style="font-size: 16px;"
|
||||||
<circle cx="12" cy="12" r="1" />
|
>more_horiz</span
|
||||||
<circle cx="19" cy="12" r="1" />
|
>
|
||||||
<circle cx="5" cy="12" r="1" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if showContextMenu}
|
{#if showContextMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="fixed bg-dark border border-light/20 rounded-lg shadow-xl py-1 z-[100] min-w-[180px]"
|
class="absolute right-0 top-full mt-1 bg-dark/95 backdrop-blur-sm border border-light/10 rounded-lg shadow-xl py-1 z-[100] min-w-[160px]"
|
||||||
style="left: {menuPosition.x}px; top: {menuPosition.y}px;"
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
|
class="w-full px-3 py-1.5 text-left text-[12px] text-light/70 hover:bg-light/5 hover:text-white flex items-center gap-2 transition-colors"
|
||||||
onclick={() => { onPin?.(); showContextMenu = false; }}
|
onclick={() => {
|
||||||
|
onPin?.();
|
||||||
|
showContextMenu = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill={isPinned ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2">
|
<span class="material-symbols-rounded" style="font-size: 16px;"
|
||||||
<path d="M12 2L12 12M12 12L8 8M12 12L16 8" transform="rotate(45 12 12)" />
|
>{isPinned ? "push_pin" : "push_pin"}</span
|
||||||
<line x1="5" y1="12" x2="19" y2="12" transform="rotate(45 12 12)" />
|
>
|
||||||
</svg>
|
{isPinned ? "Unpin" : "Pin"} message
|
||||||
{isPinned ? 'Unpin' : 'Pin'} message
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
|
class="w-full px-3 py-1.5 text-left text-[12px] text-light/70 hover:bg-light/5 hover:text-white flex items-center gap-2 transition-colors"
|
||||||
onclick={() => { navigator.clipboard.writeText(messageContent); showContextMenu = false; }}
|
onclick={() => {
|
||||||
|
navigator.clipboard.writeText(messageContent);
|
||||||
|
showContextMenu = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="material-symbols-rounded" style="font-size: 16px;"
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
>content_copy</span
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
>
|
||||||
</svg>
|
|
||||||
Copy text
|
Copy text
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
|
class="w-full px-3 py-1.5 text-left text-[12px] text-light/70 hover:bg-light/5 hover:text-white flex items-center gap-2 transition-colors"
|
||||||
onclick={() => { navigator.clipboard.writeText(messageEventId); showContextMenu = false; }}
|
onclick={() => {
|
||||||
|
navigator.clipboard.writeText(messageEventId);
|
||||||
|
showContextMenu = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="material-symbols-rounded" style="font-size: 16px;"
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
>link</span
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
>
|
||||||
</svg>
|
|
||||||
Copy message ID
|
Copy message ID
|
||||||
</button>
|
</button>
|
||||||
{#if isOwnMessage}
|
{#if isOwnMessage}
|
||||||
<div class="h-px bg-light/10 my-1"></div>
|
<div class="h-px bg-light/5 my-1"></div>
|
||||||
<button
|
<button
|
||||||
class="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-red-500/10 flex items-center gap-2"
|
class="w-full px-3 py-1.5 text-left text-[12px] text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors"
|
||||||
onclick={() => { onDelete?.(); showContextMenu = false; }}
|
onclick={() => {
|
||||||
|
onDelete?.();
|
||||||
|
showContextMenu = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="material-symbols-rounded" style="font-size: 16px;"
|
||||||
<polyline points="3,6 5,6 21,6" />
|
>delete</span
|
||||||
<path d="M19,6 v14 a2,2 0 0,1 -2,2 H7 a2,2 0 0,1 -2,-2 V6 m3,0 V4 a2,2 0 0,1 2,-2 h4 a2,2 0 0,1 2,2 v2" />
|
>
|
||||||
</svg>
|
|
||||||
Delete message
|
Delete message
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if reactions.size > 0}
|
{#if reactions.size > 0}
|
||||||
<div class="flex flex-wrap items-center gap-1 mt-1 ml-14">
|
<div class="flex flex-wrap items-center gap-1 mt-1 ml-12">
|
||||||
{#each [...reactions.entries()] as [emoji, userMap]}
|
{#each [...reactions.entries()] as [emoji, userMap]}
|
||||||
{@const hasReacted = userMap.has(currentUserId)}
|
{@const hasReacted = userMap.has(currentUserId)}
|
||||||
{@const reactionEventId = getUserReactionEventId(emoji)}
|
{@const reactionEventId = getUserReactionEventId(emoji)}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
{#if receipts.length > 0}
|
{#if receipts.length > 0}
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-1 mt-1 ml-14"
|
class="flex items-center gap-1 mt-1 ml-12"
|
||||||
title="Read by {receipts.map((r) => r.name).join(', ')}"
|
title="Read by {receipts.map((r) => r.name).join(', ')}"
|
||||||
>
|
>
|
||||||
<span class="text-xs text-light/40 mr-1">Read by</span>
|
<span class="text-xs text-light/40 mr-1">Read by</span>
|
||||||
|
|||||||
@@ -742,18 +742,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<!-- Chat toggle hidden for now -->
|
<!-- Chat toggle disabled until fully developed -->
|
||||||
<!-- <label
|
<!-- <label
|
||||||
class="flex items-center justify-between px-3 py-3 rounded-xl bg-dark/30 border border-light/5 cursor-pointer hover:border-light/10 transition-colors"
|
class="flex items-center justify-between px-3 py-3 rounded-xl bg-dark/30 border border-light/5 cursor-pointer hover:border-light/10 transition-colors"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="material-symbols-rounded text-purple-400" style="font-size: 20px;">chat</span>
|
<span
|
||||||
|
class="material-symbols-rounded text-purple-400"
|
||||||
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||||
|
>chat</span
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-body-sm text-white">Chat</p>
|
<p class="text-body-sm text-white">Chat</p>
|
||||||
<p class="text-[11px] text-light/30">Real-time messaging via Matrix</p>
|
<p class="text-[11px] text-light/30">
|
||||||
|
Real-time messaging via Matrix
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input type="checkbox" bind:checked={featureChat} class="w-4 h-4 rounded accent-primary" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={featureChat}
|
||||||
|
class="w-4 h-4 rounded accent-primary"
|
||||||
|
/>
|
||||||
</label> -->
|
</label> -->
|
||||||
|
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
|
|
||||||
let { name, src = null, size = "md", status = null }: Props = $props();
|
let { name, src = null, size = "md", status = null }: Props = $props();
|
||||||
|
|
||||||
|
let imgFailed = $state(false);
|
||||||
|
|
||||||
|
// Reset imgFailed when src changes
|
||||||
|
$effect(() => {
|
||||||
|
if (src) imgFailed = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showImg = $derived(src && !imgFailed);
|
||||||
const initial = $derived(name ? name[0].toUpperCase() : "?");
|
const initial = $derived(name ? name[0].toUpperCase() : "?");
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
@@ -35,11 +43,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative inline-block shrink-0">
|
<div class="relative inline-block shrink-0">
|
||||||
{#if src}
|
{#if showImg}
|
||||||
<img
|
<img
|
||||||
{src}
|
{src}
|
||||||
alt={name}
|
alt={name}
|
||||||
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0"
|
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0"
|
||||||
|
onerror={() => (imgFailed = true)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -9,45 +9,40 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
onSelect: (emoji: string) => void;
|
onSelect: (emoji: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
position?: { x: number; y: number };
|
position?: { x: number; y: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onSelect, onClose, position = { x: 0, y: 0 } }: Props = $props();
|
let { onSelect, onClose, position = null }: Props = $props();
|
||||||
|
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
let activeCategory = $state("frequent");
|
let activeCategory = $state("frequent");
|
||||||
let pickerRef: HTMLDivElement | null = $state(null);
|
let pickerRef: HTMLDivElement | null = $state(null);
|
||||||
let adjustedPosition = $state({ x: 0, y: 0 });
|
let adjustedPosition = $state({ x: 0, y: 0 });
|
||||||
|
|
||||||
// Initialize position on first render
|
const isInline = $derived(!position);
|
||||||
|
|
||||||
|
// Adjust position to stay within viewport (only for fixed mode)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
adjustedPosition = { x: position.x, y: position.y };
|
if (!position || !pickerRef) return;
|
||||||
});
|
|
||||||
|
|
||||||
// Adjust position to stay within viewport
|
const rect = pickerRef.getBoundingClientRect();
|
||||||
$effect(() => {
|
const viewportWidth = window.innerWidth;
|
||||||
if (pickerRef) {
|
const viewportHeight = window.innerHeight;
|
||||||
const rect = pickerRef.getBoundingClientRect();
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
let newX = position.x;
|
let newX = position.x;
|
||||||
let newY = position.y;
|
let newY = position.y;
|
||||||
|
|
||||||
// Adjust horizontal position
|
if (newX + rect.width > viewportWidth - 10) {
|
||||||
if (newX + rect.width > viewportWidth - 10) {
|
newX = viewportWidth - rect.width - 10;
|
||||||
newX = viewportWidth - rect.width - 10;
|
|
||||||
}
|
|
||||||
if (newX < 10) newX = 10;
|
|
||||||
|
|
||||||
// Adjust vertical position
|
|
||||||
if (newY + rect.height > viewportHeight - 10) {
|
|
||||||
newY = position.y - rect.height - 40; // Position above the button
|
|
||||||
}
|
|
||||||
if (newY < 10) newY = 10;
|
|
||||||
|
|
||||||
adjustedPosition = { x: newX, y: newY };
|
|
||||||
}
|
}
|
||||||
|
if (newX < 10) newX = 10;
|
||||||
|
|
||||||
|
if (newY + rect.height > viewportHeight - 10) {
|
||||||
|
newY = position.y - rect.height - 40;
|
||||||
|
}
|
||||||
|
if (newY < 10) newY = 10;
|
||||||
|
|
||||||
|
adjustedPosition = { x: newX, y: newY };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emoji categories
|
// Emoji categories
|
||||||
@@ -100,8 +95,12 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
bind:this={pickerRef}
|
bind:this={pickerRef}
|
||||||
class="fixed bg-dark border border-light/20 rounded-xl shadow-2xl z-[100] w-[352px] overflow-hidden"
|
class="{isInline
|
||||||
style="left: {adjustedPosition.x}px; top: {adjustedPosition.y}px;"
|
? ''
|
||||||
|
: 'fixed'} bg-dark border border-light/20 rounded-xl shadow-2xl z-[100] w-[352px] overflow-hidden"
|
||||||
|
style={isInline
|
||||||
|
? ""
|
||||||
|
: `left: ${adjustedPosition.x}px; top: ${adjustedPosition.y}px;`}
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onkeydown={(e) => e.stopPropagation()}
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|||||||
26
src/lib/components/ui/MatrixAvatar.svelte
Normal file
26
src/lib/components/ui/MatrixAvatar.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Avatar from './Avatar.svelte';
|
||||||
|
import { resolveAvatarUrl, getResolvedAvatarUrl, avatarCacheVersion } from '$lib/stores/avatarCache';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
mxcUrl?: string | null;
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
status?: 'online' | 'offline' | 'away' | 'dnd' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name, mxcUrl = null, size = 'md', status = null }: Props = $props();
|
||||||
|
|
||||||
|
// Trigger resolution whenever mxcUrl changes
|
||||||
|
$effect(() => {
|
||||||
|
if (mxcUrl) resolveAvatarUrl(mxcUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-derive when cache version bumps (reactive trigger)
|
||||||
|
const resolvedSrc = $derived.by(() => {
|
||||||
|
$avatarCacheVersion;
|
||||||
|
return getResolvedAvatarUrl(mxcUrl);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Avatar {name} src={resolvedSrc} {size} {status} />
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getTwemojiUrl } from '$lib/utils/twemoji';
|
import { getCachedTwemojiUrl } from "$lib/utils/twemoji";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
emoji: string;
|
emoji: string;
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { emoji, size = 20, class: className = '' }: Props = $props();
|
let { emoji, size = 20, class: className = "" }: Props = $props();
|
||||||
|
|
||||||
const url = $derived(getTwemojiUrl(emoji));
|
const url = $derived(getCachedTwemojiUrl(emoji));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
@@ -18,4 +18,5 @@
|
|||||||
class="inline-block align-text-bottom {className}"
|
class="inline-block align-text-bottom {className}"
|
||||||
style="width: {size}px; height: {size}px;"
|
style="width: {size}px; height: {size}px;"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export { default as Input } from './Input.svelte';
|
|||||||
export { default as Textarea } from './Textarea.svelte';
|
export { default as Textarea } from './Textarea.svelte';
|
||||||
export { default as Select } from './Select.svelte';
|
export { default as Select } from './Select.svelte';
|
||||||
export { default as Avatar } from './Avatar.svelte';
|
export { default as Avatar } from './Avatar.svelte';
|
||||||
|
export { default as MatrixAvatar } from './MatrixAvatar.svelte';
|
||||||
export { default as Badge } from './Badge.svelte';
|
export { default as Badge } from './Badge.svelte';
|
||||||
export { default as Card } from './Card.svelte';
|
export { default as Card } from './Card.svelte';
|
||||||
export { default as Modal } from './Modal.svelte';
|
export { default as Modal } from './Modal.svelte';
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ export function getSpaces(): Array<{ roomId: string; name: string; avatarUrl: st
|
|||||||
.map(room => ({
|
.map(room => ({
|
||||||
roomId: room.roomId,
|
roomId: room.roomId,
|
||||||
name: room.name || 'Unnamed Space',
|
name: room.name || 'Unnamed Space',
|
||||||
avatarUrl: room.getAvatarUrl(c.baseUrl, 96, 96, 'crop') || null,
|
avatarUrl: (room.currentState.getStateEvents('m.room.avatar', '')?.getContent()?.url as string) || null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,7 +865,7 @@ export function getRoomMembers(roomId: string): Array<{
|
|||||||
return members.map(member => ({
|
return members.map(member => ({
|
||||||
userId: member.userId,
|
userId: member.userId,
|
||||||
name: member.name || member.userId,
|
name: member.name || member.userId,
|
||||||
avatarUrl: member.getAvatarUrl(client!.baseUrl, 40, 40, 'crop', true, true) || null,
|
avatarUrl: member.getMxcAvatarUrl() || null,
|
||||||
membership: member.membership as 'join' | 'invite' | 'leave' | 'ban',
|
membership: member.membership as 'join' | 'invite' | 'leave' | 'ban',
|
||||||
powerLevel: userPowerLevels[member.userId] ?? defaultPowerLevel,
|
powerLevel: userPowerLevels[member.userId] ?? defaultPowerLevel,
|
||||||
}));
|
}));
|
||||||
@@ -936,7 +936,7 @@ export async function searchUsers(query: string, limit = 10): Promise<Array<{
|
|||||||
return response.results.map((user: any) => ({
|
return response.results.map((user: any) => ({
|
||||||
userId: user.user_id,
|
userId: user.user_id,
|
||||||
displayName: user.display_name || user.user_id,
|
displayName: user.display_name || user.user_id,
|
||||||
avatarUrl: user.avatar_url ? client!.mxcUrlToHttp(user.avatar_url, 40, 40, 'crop') : null,
|
avatarUrl: user.avatar_url || null,
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('User search failed', { error: e });
|
log.error('User search failed', { error: e });
|
||||||
@@ -1045,7 +1045,7 @@ export function getReadReceiptsForEvent(roomId: string, eventId: string): Array<
|
|||||||
readers.push({
|
readers.push({
|
||||||
userId: member.userId,
|
userId: member.userId,
|
||||||
name: member.name || member.userId,
|
name: member.name || member.userId,
|
||||||
avatarUrl: member.getAvatarUrl(client.baseUrl, 20, 20, 'crop', true, true) || null,
|
avatarUrl: member.getMxcAvatarUrl() || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function setupSyncHandlers(client: MatrixClient): void {
|
|||||||
// Get sender info
|
// Get sender info
|
||||||
const member = room.getMember(sender);
|
const member = room.getMember(sender);
|
||||||
const senderName = member?.name || sender;
|
const senderName = member?.name || sender;
|
||||||
const senderAvatar = member?.getAvatarUrl(client.baseUrl, 40, 40, 'crop', true, true) || null;
|
const senderAvatar = member?.getMxcAvatarUrl() || null;
|
||||||
|
|
||||||
// Determine message type
|
// Determine message type
|
||||||
const type = getMessageType(content.msgtype || 'm.text');
|
const type = getMessageType(content.msgtype || 'm.text');
|
||||||
|
|||||||
105
src/lib/stores/avatarCache.ts
Normal file
105
src/lib/stores/avatarCache.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Avatar Cache Store
|
||||||
|
*
|
||||||
|
* Resolves Matrix mxc:// avatar URLs through authenticated media endpoints
|
||||||
|
* and caches the resulting blob URLs. Falls back to legacy URLs if auth fails.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { resolveAvatarUrl, getAvatarUrl } from '$lib/stores/avatarCache';
|
||||||
|
* // Start resolving (fire-and-forget)
|
||||||
|
* resolveAvatarUrl(mxcUrl);
|
||||||
|
* // Get current resolved URL (reactive via store)
|
||||||
|
* const url = getAvatarUrl(mxcUrl);
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable, get } from 'svelte/store';
|
||||||
|
import { getClient, isClientInitialized } from '$lib/matrix/client';
|
||||||
|
|
||||||
|
// Resolved avatar URLs: mxc:// → blob URL or legacy HTTP URL
|
||||||
|
const cache = new Map<string, string>();
|
||||||
|
// In-flight fetches
|
||||||
|
const pending = new Map<string, Promise<string | null>>();
|
||||||
|
// Reactive store that increments on each cache update to trigger re-renders
|
||||||
|
export const avatarCacheVersion = writable(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an mxc:// URL to an authenticated blob URL.
|
||||||
|
* Returns immediately if cached. Fetches in background otherwise.
|
||||||
|
*/
|
||||||
|
export function resolveAvatarUrl(mxcUrl: string | null | undefined): void {
|
||||||
|
if (!mxcUrl || !mxcUrl.startsWith('mxc://') || cache.has(mxcUrl) || pending.has(mxcUrl)) return;
|
||||||
|
if (!isClientInitialized()) return;
|
||||||
|
|
||||||
|
const client = getClient();
|
||||||
|
const match = mxcUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/);
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
|
const [, serverName, mediaId] = match;
|
||||||
|
const accessToken = client.getAccessToken();
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
// Try authenticated thumbnail endpoint first (smaller, faster)
|
||||||
|
const url = `${client.baseUrl}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=80&height=80&method=crop`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
cache.set(mxcUrl, blobUrl);
|
||||||
|
avatarCacheVersion.update(v => v + 1);
|
||||||
|
return blobUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy unauthenticated URL
|
||||||
|
const legacyUrl = client.mxcUrlToHttp(mxcUrl, 80, 80, 'crop');
|
||||||
|
if (legacyUrl) {
|
||||||
|
cache.set(mxcUrl, legacyUrl);
|
||||||
|
avatarCacheVersion.update(v => v + 1);
|
||||||
|
return legacyUrl;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
// Last resort fallback
|
||||||
|
const legacyUrl = client.mxcUrlToHttp(mxcUrl, 80, 80, 'crop');
|
||||||
|
if (legacyUrl) {
|
||||||
|
cache.set(mxcUrl, legacyUrl);
|
||||||
|
avatarCacheVersion.update(v => v + 1);
|
||||||
|
}
|
||||||
|
return legacyUrl;
|
||||||
|
} finally {
|
||||||
|
pending.delete(mxcUrl);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
pending.set(mxcUrl, promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the resolved URL for an avatar.
|
||||||
|
* Returns cached blob URL, or null if not yet resolved.
|
||||||
|
* Call resolveAvatarUrl() first to start the fetch.
|
||||||
|
*/
|
||||||
|
export function getResolvedAvatarUrl(mxcUrl: string | null | undefined): string | null {
|
||||||
|
if (!mxcUrl) return null;
|
||||||
|
// Non-mxc URLs (e.g. Supabase storage URLs) pass through directly
|
||||||
|
if (!mxcUrl.startsWith('mxc://')) return mxcUrl;
|
||||||
|
return cache.get(mxcUrl) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the avatar cache (e.g. on logout)
|
||||||
|
*/
|
||||||
|
export function clearAvatarCache(): void {
|
||||||
|
// Revoke blob URLs to free memory
|
||||||
|
for (const url of cache.values()) {
|
||||||
|
if (url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache.clear();
|
||||||
|
pending.clear();
|
||||||
|
avatarCacheVersion.set(0);
|
||||||
|
}
|
||||||
@@ -217,7 +217,7 @@ function roomToSummary(room: Room, spaceChildMap: Map<string, string>): RoomSumm
|
|||||||
return {
|
return {
|
||||||
roomId: room.roomId,
|
roomId: room.roomId,
|
||||||
name: room.name || 'Unnamed Room',
|
name: room.name || 'Unnamed Room',
|
||||||
avatarUrl: room.getAvatarUrl(getClient()?.baseUrl || '', 40, 40, 'crop') || null,
|
avatarUrl: (room.currentState.getStateEvents('m.room.avatar', '') as MatrixEvent | null)?.getContent()?.url || null,
|
||||||
topic: (room.currentState.getStateEvents('m.room.topic', '') as MatrixEvent | null)?.getContent()?.topic || null,
|
topic: (room.currentState.getStateEvents('m.room.topic', '') as MatrixEvent | null)?.getContent()?.topic || null,
|
||||||
isDirect: room.getDMInviter() !== undefined,
|
isDirect: room.getDMInviter() !== undefined,
|
||||||
isEncrypted: room.hasEncryptionStateEvent(),
|
isEncrypted: room.hasEncryptionStateEvent(),
|
||||||
@@ -441,7 +441,7 @@ function eventToMessage(event: MatrixEvent, room?: Room | null, skipCache = fals
|
|||||||
const member = roomObj.getMember(sender);
|
const member = roomObj.getMember(sender);
|
||||||
if (member) {
|
if (member) {
|
||||||
senderName = member.name || sender;
|
senderName = member.name || sender;
|
||||||
senderAvatar = member.getAvatarUrl(client.baseUrl, 40, 40, 'crop', true, true) || null;
|
senderAvatar = member.getMxcAvatarUrl() || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -801,8 +801,16 @@ export async function selectRoom(roomId: string | null): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Then load fresh messages (will update/replace cached)
|
// Load fresh messages from the live timeline
|
||||||
loadRoomMessages(roomId);
|
loadRoomMessages(roomId);
|
||||||
|
|
||||||
|
// Auto-paginate to load more history (since we removed the load-more button)
|
||||||
|
try {
|
||||||
|
const { loadMoreMessages } = await import('$lib/matrix');
|
||||||
|
await loadMoreMessages(roomId, 100);
|
||||||
|
} catch {
|
||||||
|
// Silently ignore - initial messages are still available
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,4 +846,7 @@ export function clearState(): void {
|
|||||||
typingByRoom.set(new Map());
|
typingByRoom.set(new Map());
|
||||||
userPresence.set(new Map());
|
userPresence.set(new Map());
|
||||||
messageCache.clear();
|
messageCache.clear();
|
||||||
|
|
||||||
|
// Clear avatar cache
|
||||||
|
import('$lib/stores/avatarCache').then(m => m.clearAvatarCache()).catch(() => { });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Twemoji utility for rendering emojis as Twitter-style images
|
* Twemoji utility for rendering emojis as Twitter-style images
|
||||||
|
* Includes in-memory blob URL cache for instant re-renders
|
||||||
*/
|
*/
|
||||||
import twemoji from 'twemoji';
|
import twemoji from 'twemoji';
|
||||||
|
|
||||||
|
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/';
|
||||||
|
|
||||||
|
// In-memory cache: codepoint → blob URL
|
||||||
|
const blobCache = new Map<string, string>();
|
||||||
|
// Track in-flight fetches to avoid duplicate requests
|
||||||
|
const fetchPromises = new Map<string, Promise<string>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse text and replace emojis with Twemoji images
|
* Parse text and replace emojis with Twemoji images
|
||||||
*/
|
*/
|
||||||
@@ -16,15 +24,61 @@ export function parseTwemoji(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Twemoji image URL for a single emoji
|
* Get the codepoint string for an emoji
|
||||||
*/
|
*/
|
||||||
export function getTwemojiUrl(emoji: string): string {
|
function emojiToCodepoint(emoji: string): string {
|
||||||
// Remove variation selector (FE0F) as Twemoji uses base codepoints
|
return [...emoji]
|
||||||
const codePoint = [...emoji]
|
|
||||||
.filter((char) => char.codePointAt(0) !== 0xfe0f)
|
.filter((char) => char.codePointAt(0) !== 0xfe0f)
|
||||||
.map((char) => char.codePointAt(0)?.toString(16))
|
.map((char) => char.codePointAt(0)?.toString(16))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('-')
|
.join('-')
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codePoint}.svg`;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Twemoji image URL for a single emoji (CDN URL, no caching)
|
||||||
|
*/
|
||||||
|
export function getTwemojiUrl(emoji: string): string {
|
||||||
|
const cp = emojiToCodepoint(emoji);
|
||||||
|
return `${TWEMOJI_BASE}${cp}.svg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a cached blob URL for a Twemoji SVG.
|
||||||
|
* Returns the CDN URL immediately, then fetches + caches in background.
|
||||||
|
* On subsequent calls, returns the cached blob URL instantly.
|
||||||
|
*/
|
||||||
|
export function getCachedTwemojiUrl(emoji: string): string {
|
||||||
|
const cp = emojiToCodepoint(emoji);
|
||||||
|
if (blobCache.has(cp)) return blobCache.get(cp)!;
|
||||||
|
|
||||||
|
const cdnUrl = `${TWEMOJI_BASE}${cp}.svg`;
|
||||||
|
|
||||||
|
// Start background fetch if not already in flight
|
||||||
|
if (!fetchPromises.has(cp)) {
|
||||||
|
const promise = fetch(cdnUrl)
|
||||||
|
.then(r => r.blob())
|
||||||
|
.then(blob => {
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
blobCache.set(cp, blobUrl);
|
||||||
|
fetchPromises.delete(cp);
|
||||||
|
return blobUrl;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
fetchPromises.delete(cp);
|
||||||
|
return cdnUrl;
|
||||||
|
});
|
||||||
|
fetchPromises.set(cp, promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cdnUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload frequently used emojis into the blob cache
|
||||||
|
*/
|
||||||
|
export function preloadTwemojis(emojis: string[]): void {
|
||||||
|
for (const emoji of emojis) {
|
||||||
|
getCachedTwemojiUrl(emoji);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
label: m.nav_events(),
|
label: m.nav_events(),
|
||||||
icon: "celebration",
|
icon: "celebration",
|
||||||
},
|
},
|
||||||
// Chat disabled for now (feature_chat still in DB)
|
// Chat disabled until fully developed
|
||||||
// ...(data.org.feature_chat
|
// ...(data.org.feature_chat
|
||||||
// ? [
|
// ? [
|
||||||
// {
|
// {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
19
supabase/migrations/058_fix_handle_new_org_trigger.sql
Normal file
19
supabase/migrations/058_fix_handle_new_org_trigger.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Fix handle_new_org trigger: use auth.uid() instead of NEW.created_by
|
||||||
|
-- The organizations table has no created_by column, so the trigger was inserting NULL
|
||||||
|
-- as user_id into org_members, causing org creation to fail.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_org()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Add creator as owner (use auth.uid() since organizations has no created_by column)
|
||||||
|
INSERT INTO public.org_members (org_id, user_id, role, joined_at)
|
||||||
|
VALUES (NEW.id, auth.uid(), 'owner', now());
|
||||||
|
-- Create default roles
|
||||||
|
PERFORM public.create_default_org_roles(NEW.id);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user