MEga push vol idk, chat function updates, docker fixes

This commit is contained in:
AlacrisDevs
2026-02-14 13:09:45 +02:00
parent c2d3caaa5a
commit 7ab206fe96
35 changed files with 1226 additions and 1344 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -268,8 +268,6 @@
{onEdit} {onEdit}
{onDelete} {onDelete}
{onReply} {onReply}
{onLoadMore}
isLoading={isLoadingMore}
/> />
<TypingIndicator userNames={typingUsers} /> <TypingIndicator userNames={typingUsers} />
<MessageInput <MessageInput

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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"
> >

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View 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} />

View File

@@ -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"
/> />

View File

@@ -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';

View File

@@ -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,
}); });
} }
} }

View File

@@ -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');

View 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);
}

View File

@@ -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(() => { });
} }

View File

@@ -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);
}
} }

View File

@@ -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

View 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.