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
build
**/.env
**/.env.*
**/.env.local
**/.env.*.local
*.log
.DS_Store

View File

@@ -1,18 +1,25 @@
# ── Supabase ──
PUBLIC_SUPABASE_URL=your_supabase_url
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
# Service role key — required for admin operations (invite emails, etc.)
# Find it in Supabase Dashboard → Settings → API → service_role key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
# ── Google ──
GOOGLE_API_KEY=your_google_api_key
# Google Service Account for Calendar push (create/update/delete events)
# 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)
GOOGLE_SERVICE_ACCOUNT_KEY=
# Matrix / Synapse integration
# ── Matrix / Synapse (optional — chat is not yet enabled) ──
# The homeserver URL where your Synapse instance is running
MATRIX_HOMESERVER_URL=https://matrix.example.com
# Synapse Admin API shared secret or admin access token
# Used to auto-provision Matrix accounts for users
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
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 ./
# Install all dependencies (including dev)
# Install all dependencies (including dev) needed for the build
RUN npm ci
# Copy source files
COPY . .
# Build the application
# Build the SvelteKit application
RUN npm run build
# Prune dev dependencies
RUN npm prune --production
# ── Production dependencies stage ──
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
WORKDIR /app
# Copy built application
# Copy built application and production deps
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 .
# Expose port
EXPOSE 3000
# Set environment
# Set environment defaults
ENV NODE_ENV=production
ENV PORT=3000
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
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
# Run the application

View File

@@ -3,31 +3,26 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL}
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY}
ports:
- "3000:3000"
env_file: .env
environment:
- NODE_ENV=production
- PORT=3000
- HOST=0.0.0.0
# Supabase
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL}
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY}
# 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}
# SvelteKit node adapter CSRF — set to your public URL in production
- ORIGIN=${ORIGIN:-http://localhost:3000}
- BODY_SIZE_LIMIT=10485760
restart: unless-stopped
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
timeout: 3s
retries: 3
start_period: 5s
start_period: 10s
# Development mode with hot reload
dev:
@@ -39,8 +34,7 @@ services:
volumes:
- .:/app
- /app/node_modules
env_file: .env
environment:
- NODE_ENV=development
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL}
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY}
command: npm run dev -- --host

View File

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

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import { MatrixAvatar } from "$lib/components/ui";
import UserProfileModal from "./UserProfileModal.svelte";
import type { RoomMember } from "$lib/matrix/types";
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"
onclick={() => handleMemberClick(member)}
>
<Avatar
src={member.avatarUrl}
<MatrixAvatar
mxcUrl={member.avatarUrl}
name={member.name}
size="sm"
status={getPresenceStatus(member.userId)}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import type { RoomMember } from '$lib/matrix/types';
import { MatrixAvatar } from "$lib/components/ui";
import type { RoomMember } from "$lib/matrix/types";
interface Props {
members: RoomMember[];
@@ -23,11 +23,12 @@
// Filter members based on query
const filteredMembers = $derived(
members
.filter(m =>
m.name.toLowerCase().includes(query.toLowerCase()) ||
m.userId.toLowerCase().includes(query.toLowerCase())
.filter(
(m) =>
m.name.toLowerCase().includes(query.toLowerCase()) ||
m.userId.toLowerCase().includes(query.toLowerCase()),
)
.slice(0, 8)
.slice(0, 8),
);
// Reset selection when query changes
@@ -40,22 +41,23 @@
if (filteredMembers.length === 0) return;
switch (e.key) {
case 'ArrowDown':
case "ArrowDown":
e.preventDefault();
selectedIndex = (selectedIndex + 1) % filteredMembers.length;
break;
case 'ArrowUp':
case "ArrowUp":
e.preventDefault();
selectedIndex = (selectedIndex - 1 + filteredMembers.length) % filteredMembers.length;
selectedIndex =
(selectedIndex - 1 + filteredMembers.length) % filteredMembers.length;
break;
case 'Enter':
case 'Tab':
case "Enter":
case "Tab":
e.preventDefault();
if (filteredMembers[selectedIndex]) {
onSelect(filteredMembers[selectedIndex]);
}
break;
case 'Escape':
case "Escape":
e.preventDefault();
onClose();
break;
@@ -76,11 +78,14 @@
</div>
{#each filteredMembers as member, i}
<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)}
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">
<p class="text-light truncate">{member.name}</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
function renderEmojiPreview(text: string): string {
// Escape HTML first
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>");
// Replace emojis with Twemoji images
return escaped.replace(emojiRegex, (emoji) => {
const url = getTwemojiUrl(emoji);
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
let showEmojiPicker = $state(false);
let emojiButtonRef: HTMLButtonElement;
// Emoji autocomplete state
let showEmojiAutocomplete = $state(false);
@@ -125,22 +122,17 @@
function autoResize() {
if (!inputRef) return;
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
function handleTyping() {
// Clear existing timeout
if (typingTimeout) {
clearTimeout(typingTimeout);
}
if (typingTimeout) clearTimeout(typingTimeout);
// Send typing indicator
setTyping(roomId, true).catch((e) =>
log.error("Failed to send typing", { error: e }),
);
// Stop typing after 3 seconds of no input
typingTimeout = setTimeout(() => {
setTyping(roomId, false).catch((e) =>
log.error("Failed to stop typing", { error: e }),
@@ -151,31 +143,20 @@
// Handle input
function handleInput() {
autoResize();
if (message.trim()) {
handleTyping();
}
// Auto-convert completed emoji shortcodes like :heart: to actual emojis
if (message.trim()) handleTyping();
autoConvertShortcodes();
// Check for @ mentions and : emoji shortcodes
checkForMention();
checkForEmoji();
}
// Auto-convert completed emoji shortcodes (e.g., :heart:) to actual emojis
// Auto-convert completed emoji shortcodes
function autoConvertShortcodes() {
if (!inputRef) return;
const cursorPos = inputRef.selectionStart;
// Look for completed shortcodes like :name:
const converted = convertEmojiShortcodes(message);
if (converted !== message) {
// Calculate cursor offset based on length difference
const lengthDiff = message.length - converted.length;
message = converted;
// Restore cursor position (adjusted for shorter string)
setTimeout(() => {
if (inputRef) {
const newPos = Math.max(0, cursorPos - lengthDiff);
@@ -188,16 +169,12 @@
// Check if user is typing an emoji shortcode
function checkForEmoji() {
if (!inputRef) return;
const cursorPos = inputRef.selectionStart;
const textBeforeCursor = message.slice(0, cursorPos);
// Find the last : before cursor
const lastColonIndex = textBeforeCursor.lastIndexOf(":");
if (lastColonIndex >= 0) {
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 =
lastColonIndex > 0 ? message[lastColonIndex - 1] : " ";
@@ -222,31 +199,23 @@
// Handle emoji selection from autocomplete
function handleEmojiSelect(emoji: string) {
// Replace :query with the emoji
const beforeEmoji = message.slice(0, emojiStartIndex);
const afterEmoji = message.slice(emojiStartIndex + emojiQuery.length + 1);
message = `${beforeEmoji}${emoji}${afterEmoji}`;
showEmojiAutocomplete = false;
emojiQuery = "";
// Focus back on textarea
inputRef?.focus();
}
// Check if user is typing a mention
function checkForMention() {
if (!inputRef) return;
const cursorPos = inputRef.selectionStart;
const textBeforeCursor = message.slice(0, cursorPos);
// Find the last @ before cursor that's not part of a completed mention
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
if (lastAtIndex >= 0) {
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] : " ";
if (
@@ -266,23 +235,19 @@
// Handle mention selection
function handleMentionSelect(member: RoomMember) {
// Replace @query with userId (userId already has @ prefix)
const beforeMention = message.slice(0, mentionStartIndex);
const afterMention = message.slice(
mentionStartIndex + mentionQuery.length + 1,
);
message = `${beforeMention}${member.userId} ${afterMention}`;
showMentions = false;
mentionQuery = "";
// Focus back on textarea
inputRef?.focus();
}
// Handle key press
function handleKeyDown(e: KeyboardEvent) {
// If mention autocomplete is open, let it handle navigation keys
// Mention autocomplete navigation
if (
showMentions &&
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
@@ -290,15 +255,13 @@
autocompleteRef?.handleKeyDown(e);
return;
}
// Enter with mention autocomplete open selects the mention
if (showMentions && e.key === "Enter") {
e.preventDefault();
autocompleteRef?.handleKeyDown(e);
return;
}
// If emoji autocomplete is open, let it handle navigation keys
// Emoji autocomplete navigation
if (
showEmojiAutocomplete &&
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
@@ -306,8 +269,6 @@
emojiAutocompleteRef?.handleKeyDown(e);
return;
}
// Enter with emoji autocomplete open selects the emoji
if (showEmojiAutocomplete && e.key === "Enter") {
e.preventDefault();
emojiAutocompleteRef?.handleKeyDown(e);
@@ -321,70 +282,42 @@
return;
}
// Auto-continue lists on Shift+Enter or regular Enter with list
// Auto-continue lists on Shift+Enter
if (e.key === "Enter" && e.shiftKey) {
const cursorPos = inputRef?.selectionStart || 0;
const textBefore = message.slice(0, cursorPos);
const currentLine = textBefore.split("\n").pop() || "";
// Check for numbered list (1. 2. etc)
const numberedMatch = currentLine.match(/^(\s*)(\d+)\.\s/);
if (numberedMatch) {
e.preventDefault();
const indent = numberedMatch[1];
const nextNum = parseInt(numberedMatch[2]) + 1;
const newText =
message =
message.slice(0, cursorPos) +
`\n${indent}${nextNum}. ` +
message.slice(cursorPos);
message = newText;
setTimeout(() => {
if (inputRef) {
if (inputRef)
inputRef.selectionStart = inputRef.selectionEnd =
cursorPos + indent.length + String(nextNum).length + 4;
}
}, 0);
return;
}
// Check for bullet list (- or *)
const bulletMatch = currentLine.match(/^(\s*)([-*])\s/);
if (bulletMatch) {
e.preventDefault();
const indent = bulletMatch[1];
const bullet = bulletMatch[2];
const newText =
message =
message.slice(0, cursorPos) +
`\n${indent}${bullet} ` +
message.slice(cursorPos);
message = newText;
setTimeout(() => {
if (inputRef) {
if (inputRef)
inputRef.selectionStart = inputRef.selectionEnd =
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);
return;
}
@@ -396,28 +329,23 @@
const trimmed = message.trim();
if (!trimmed || isSending || disabled) return;
// Convert emoji shortcodes like :heart: to actual emojis
const processedMessage = convertEmojiShortcodes(trimmed);
// Handle edit mode
if (editingMessage) {
if (processedMessage === editingMessage.content) {
// No changes, just cancel
onCancelEdit?.();
message = "";
return;
}
onSaveEdit?.(processedMessage);
message = "";
if (inputRef) {
inputRef.style.height = "auto";
}
if (inputRef) inputRef.style.height = "auto";
return;
}
isSending = true;
// Clear typing indicator
if (typingTimeout) {
clearTimeout(typingTimeout);
typingTimeout = null;
@@ -426,10 +354,8 @@
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)}`;
// Add pending message immediately (optimistic update)
const pendingMessage: Message = {
eventId: tempEventId,
roomId,
@@ -448,14 +374,9 @@
addPendingMessage(roomId, pendingMessage);
message = "";
// Clear reply
onCancelReply?.();
// Reset textarea height
if (inputRef) {
inputRef.style.height = "auto";
}
if (inputRef) inputRef.style.height = "auto";
try {
const result = await sendMessage(
@@ -463,21 +384,17 @@
processedMessage,
replyTo?.eventId,
);
// Confirm the pending message with the real event ID
if (result?.event_id) {
confirmPendingMessage(roomId, tempEventId, result.event_id);
} else {
// If no event ID returned, just mark as not pending
confirmPendingMessage(roomId, tempEventId, tempEventId);
}
} catch (e: unknown) {
log.error("Failed to send message", { error: e });
// Remove the pending message on failure
removePendingMessage(roomId, tempEventId);
toasts.error(getErrorMessage(e, "Failed to send message"));
} finally {
isSending = false;
// Refocus after DOM settles from optimistic update
await tick();
inputRef?.focus();
}
@@ -488,11 +405,8 @@
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || disabled) return;
// Reset input
input.value = "";
// Check file size (50MB limit)
const maxSize = 50 * 1024 * 1024;
if (file.size > maxSize) {
toasts.error(m.toast_error_file_too_large());
@@ -518,35 +432,34 @@
}
</script>
<div class="border-t border-light/10">
<div class="border-t border-light/5">
<!-- Edit preview -->
{#if editingMessage}
<div class="px-4 pt-3 pb-0">
<div
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">
<p class="text-xs text-yellow-400 font-medium">Editing message</p>
<p class="text-sm text-light/60 truncate">{editingMessage.content}</p>
<p class="text-[11px] text-yellow-400 font-body">Editing message</p>
<p class="text-[12px] text-light/50 truncate">
{editingMessage.content}
</p>
</div>
<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={() => {
onCancelEdit?.();
message = "";
}}
title="Cancel 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;"
>close</span
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
@@ -556,35 +469,47 @@
{#if replyTo && !editingMessage}
<div class="px-4 pt-3 pb-0">
<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">
<p class="text-xs text-primary font-medium">
<p class="text-[11px] text-primary font-body">
Replying to {replyTo.senderName}
</p>
<p class="text-sm text-light/60 truncate">{replyTo.content}</p>
<p class="text-[12px] text-light/50 truncate">{replyTo.content}</p>
</div>
<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?.()}
title="Cancel 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;"
>close</span
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{/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 -->
<input
bind:this={fileInputRef}
@@ -596,40 +521,20 @@
<!-- Attachment 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}
title="Add attachment"
onclick={openFilePicker}
disabled={disabled || isUploading}
>
{#if isUploading}
<svg class="w-5 h-5 animate-spin" 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>
<div
class="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin"
></div>
{:else}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span class="material-symbols-rounded" style="font-size: 20px;"
>attach_file</span
>
<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}
</button>
@@ -656,13 +561,13 @@
/>
{/if}
<!-- Input wrapper with emoji button inside -->
<!-- Input wrapper -->
<div class="relative flex items-end">
<!-- Emoji preview overlay - shows rendered Twemoji -->
<!-- Emoji preview overlay -->
{#if message && hasEmoji(message)}
<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"
style="min-height: 48px; max-height: 200px; line-height: 1.5;"
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: 40px; max-height: 160px; line-height: 1.5;"
aria-hidden="true"
>
{@html renderEmojiPreview(message)}
@@ -676,96 +581,63 @@
{placeholder}
disabled={disabled || isSending}
rows="1"
class="w-full pl-4 pr-12 py-3 bg-dark rounded-2xl border border-light/20
placeholder:text-light/40 resize-none overflow-hidden
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
class="w-full pl-3 pr-10 py-2.5 bg-dark/50 rounded-xl border border-light/5 text-[13px]
placeholder:text-light/25 resize-none overflow-hidden
focus:outline-none focus:border-light/15
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors {message && hasEmoji(message)
? 'text-transparent caret-light'
: 'text-light'}"
style="min-height: 48px; max-height: 200px;"
style="min-height: 40px; max-height: 160px;"
></textarea>
<!-- Emoji button inside input -->
<button
bind:this={emojiButtonRef}
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)}
title="Add emoji"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span class="material-symbols-rounded" style="font-size: 18px;"
>sentiment_satisfied</span
>
<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>
</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>
<!-- Send 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()
? 'bg-primary text-white hover:brightness-110'
: 'bg-light/10 text-light/30 cursor-not-allowed'}"
: 'text-light/20 cursor-not-allowed'}"
onclick={handleSend}
disabled={!message.trim() || isSending || disabled}
title="Send message"
>
{#if isSending}
<svg class="w-5 h-5 animate-spin" 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>
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 1;">send</span
>
{/if}
</button>
</div>
<!-- Character count (optional, show when > 1000) -->
<!-- Character count -->
{#if message.length > 1000}
<div
class="text-right text-xs mt-1 {message.length > 4000
? 'text-red-400'
: 'text-light/40'}"
>
{message.length} / 4000
<div class="px-4 pb-2 text-right">
<span
class="text-[10px] {message.length > 4000
? 'text-red-400'
: 'text-light/25'}"
>
{message.length} / 4000
</span>
</div>
{/if}
</div>

View File

@@ -1,7 +1,5 @@
<script lang="ts">
import { onMount, tick, untrack } from "svelte";
import { createVirtualizer, elementScroll } from "@tanstack/svelte-virtual";
import type { SvelteVirtualizer } from "@tanstack/svelte-virtual";
import { onMount, tick } from "svelte";
import { MessageContainer } from "$lib/components/message";
import type { Message as MessageType } from "$lib/matrix/types";
import { auth } from "$lib/stores/matrix";
@@ -17,9 +15,6 @@
onEdit?: (message: MessageType) => void;
onDelete?: (messageId: string) => void;
onReply?: (message: MessageType) => void;
onLoadMore?: () => void;
isLoading?: boolean;
enableVirtualization?: boolean;
}
let {
@@ -29,175 +24,23 @@
onEdit,
onDelete,
onReply,
onLoadMore,
isLoading = false,
enableVirtualization = false, // Disabled until we find a Svelte 5-compatible solution
}: Props = $props();
let containerRef: HTMLDivElement | undefined = $state();
let shouldAutoScroll = $state(true);
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));
// Virtualizer state - managed via subscription
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
// Track scroll position to decide auto-scroll
function handleScroll() {
if (!containerRef) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef;
// Check if at bottom for auto-scroll
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
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
async function scrollToBottom(force = false) {
if (!containerRef) return;
@@ -207,25 +50,20 @@
}
}
// Auto-scroll when new messages arrive (only if at bottom)
// Auto-scroll when new messages arrive
$effect(() => {
const count = allVisibleMessages.length;
if (count > previousMessageCount) {
if (shouldAutoScroll || previousMessageCount === 0) {
// User is at bottom or first load - scroll to new messages
scrollToBottom(true);
}
// If user is scrolled up, scroll anchoring handles it
}
previousMessageCount = count;
});
// Initial scroll to bottom
onMount(() => {
tick().then(() => {
scrollToBottom(true);
});
tick().then(() => scrollToBottom(true));
});
// Check if message should be grouped with previous
@@ -235,8 +73,6 @@
): boolean {
if (!previous) return false;
if (current.sender !== previous.sender) return false;
// Group if within 5 minutes
const timeDiff = current.timestamp - previous.timestamp;
return timeDiff < 5 * 60 * 1000;
}
@@ -247,10 +83,8 @@
previous: MessageType | null,
): boolean {
if (!previous) return true;
const currentDate = new Date(current.timestamp).toDateString();
const previousDate = new Date(previous.timestamp).toDateString();
return currentDate !== previousDate;
}
@@ -314,9 +148,8 @@
const element = document.getElementById(`message-${eventId}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
// Highlight briefly
element.classList.add("bg-primary/20");
setTimeout(() => element.classList.remove("bg-primary/20"), 2000);
element.classList.add("bg-primary/10");
setTimeout(() => element.classList.remove("bg-primary/10"), 2000);
}
}
</script>
@@ -324,97 +157,23 @@
<div class="relative flex-1 min-h-0">
<div
bind:this={containerRef}
class="h-full overflow-y-auto bg-night"
class="h-full overflow-y-auto scrollbar-thin"
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}
<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
class="w-16 h-16 mb-4 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
<span
class="material-symbols-rounded mb-3"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 200, 'GRAD' 0, 'opsz' 48;"
>forum</span
>
<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>
<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}
<p class="text-body-sm text-light/40 mb-1">No messages yet</p>
<p class="text-[12px] text-light/20">Be the first to send a message!</p>
</div>
{:else}
<!-- Fallback: Non-virtualized rendering for small lists -->
<div class="py-4">
<div class="py-2">
{#each allVisibleMessages as message, i (message.eventId)}
{@const previousMessage = i > 0 ? allVisibleMessages[i - 1] : null}
{@const isGrouped = shouldGroup(message, previousMessage)}
@@ -425,12 +184,12 @@
<!-- 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">
<div class="flex items-center gap-3 px-5 py-3">
<div class="flex-1 h-px bg-light/5"></div>
<span class="text-[11px] text-light/30 font-body select-none">
{formatDateSeparator(message.timestamp)}
</span>
<div class="flex-1 h-px bg-light/10"></div>
<div class="flex-1 h-px bg-light/5"></div>
</div>
{/if}
@@ -455,24 +214,19 @@
{/if}
</div>
<!-- Scroll to bottom button -->
<!-- Scroll to bottom FAB -->
{#if !shouldAutoScroll && allVisibleMessages.length > 0}
<button
class="absolute bottom-4 right-4 p-3 bg-primary text-white rounded-full shadow-lg
hover:bg-primary/90 transition-all transform hover:scale-105
animate-in fade-in slide-in-from-bottom-2 duration-200"
class="absolute bottom-3 right-3 w-9 h-9 flex items-center justify-center
bg-dark/80 backdrop-blur-sm border border-light/10 text-light/60
rounded-full shadow-lg hover:text-white hover:border-light/20
transition-all"
onclick={() => scrollToBottom(true)}
title="Scroll to bottom"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span class="material-symbols-rounded" style="font-size: 20px;"
>keyboard_arrow_down</span
>
<polyline points="6,9 12,15 18,9" />
</svg>
</button>
{/if}
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import { MatrixAvatar } from "$lib/components/ui";
import RoomSettingsModal from "./RoomSettingsModal.svelte";
import {
getRoomNotificationLevel,
@@ -52,141 +52,104 @@
}
</script>
<div class="h-full flex flex-col bg-dark/50">
<div class="h-full flex flex-col">
<!-- Header -->
<div class="p-4 border-b border-light/10 flex items-center justify-between">
<h2 class="font-semibold text-light">Room Info</h2>
<div
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
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}
title="Close"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span class="material-symbols-rounded" style="font-size: 18px;"
>close</span
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- 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 -->
<div class="text-center">
<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>
<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}
<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}
<button
class="mt-3 px-4 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
onclick={() => (showSettings = true)}
>
<span class="inline-flex items-center gap-1">
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex items-center justify-center gap-2 mt-3">
<button
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="material-symbols-rounded" style="font-size: 16px;"
>settings</span
>
<circle cx="12" cy="12" r="3" />
<path
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"
/>
</svg>
Edit Settings
</span>
</button>
<button
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'
: 'text-light/60 hover:text-light hover:bg-light/10'}"
onclick={toggleMute}
disabled={isTogglingMute}
>
<span class="inline-flex items-center gap-1">
{#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>
Settings
</button>
<button
class="px-3 py-1.5 text-[12px] rounded-lg transition-colors inline-flex items-center gap-1.5
{isMuted
? 'bg-red-500/10 text-red-400 hover:bg-red-500/20'
: 'text-light/50 hover:text-white hover:bg-light/5'}"
onclick={toggleMute}
disabled={isTogglingMute}
>
<span class="material-symbols-rounded" style="font-size: 16px;">
{isMuted ? "notifications_off" : "notifications"}
</span>
{isMuted ? "Muted" : "Notifications"}
</button>
</div>
</div>
<!-- Room Stats -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-night rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-light">{room.memberCount}</p>
<p class="text-xs text-light/50">Members</p>
<div class="grid grid-cols-2 gap-2">
<div class="bg-dark/30 border border-light/5 rounded-lg p-3 text-center">
<p class="text-[18px] font-heading text-white">{room.memberCount}</p>
<p class="text-[10px] text-light/30">Members</p>
</div>
<div class="bg-night rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-light">
{room.isEncrypted ? "🔒" : "🔓"}
</p>
<p class="text-xs text-light/50">
<div class="bg-dark/30 border border-light/5 rounded-lg p-3 text-center">
<span
class="material-symbols-rounded text-light/40"
style="font-size: 20px;"
>
{room.isEncrypted ? "lock" : "lock_open"}
</span>
<p class="text-[10px] text-light/30 mt-0.5">
{room.isEncrypted ? "Encrypted" : "Not Encrypted"}
</p>
</div>
</div>
<!-- Room Details -->
<div class="space-y-3">
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
<div class="space-y-2">
<h4 class="text-[10px] font-body text-light/25 uppercase tracking-wider">
Details
</h4>
<div class="space-y-2 text-sm">
<div class="space-y-1.5 text-[12px]">
<div class="flex justify-between">
<span class="text-light/50">Room ID</span>
<span class="text-light/35">Room ID</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}
>
{room.roomId}
</span>
</div>
<div class="flex justify-between">
<span class="text-light/50">Type</span>
<span class="text-light"
<span class="text-light/35">Type</span>
<span class="text-light/60"
>{room.isDirect ? "Direct Message" : "Room"}</span
>
</div>
{#if room.lastActivity}
<div class="flex justify-between">
<span class="text-light/50">Last Activity</span>
<span class="text-light">{formatDate(room.lastActivity)}</span>
<span class="text-light/35">Last Activity</span>
<span class="text-light/60">{formatDate(room.lastActivity)}</span>
</div>
{/if}
</div>
@@ -194,20 +157,29 @@
<!-- Members by Role -->
{#if admins.length > 0}
<div class="space-y-2">
<div class="space-y-1.5">
<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})
</h4>
<ul class="space-y-1">
<ul class="space-y-0.5">
{#each admins as member}
<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" />
<span class="text-sm text-light truncate">{member.name}</span>
<span class="ml-auto text-xs text-yellow-400">👑</span>
<MatrixAvatar
mxcUrl={member.avatarUrl}
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>
{/each}
</ul>
@@ -215,41 +187,55 @@
{/if}
{#if moderators.length > 0}
<div class="space-y-2">
<div class="space-y-1.5">
<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})
</h4>
<ul class="space-y-1">
<ul class="space-y-0.5">
{#each moderators as member}
<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" />
<span class="text-sm text-light truncate">{member.name}</span>
<span class="ml-auto text-xs text-blue-400">🛡️</span>
<MatrixAvatar
mxcUrl={member.avatarUrl}
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>
{/each}
</ul>
</div>
{/if}
<div class="space-y-2">
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
<div class="space-y-1.5">
<h4 class="text-[10px] font-body text-light/25 uppercase tracking-wider">
Members ({regularMembers.length})
</h4>
<ul class="space-y-1">
<ul class="space-y-0.5">
{#each regularMembers.slice(0, 20) as member}
<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" />
<span class="text-sm text-light truncate">{member.name}</span>
<MatrixAvatar
mxcUrl={member.avatarUrl}
name={member.name}
size="xs"
/>
<span class="text-[12px] text-light/70 truncate">{member.name}</span
>
</li>
{/each}
{#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
</li>
{/if}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import { MatrixAvatar } from "$lib/components/ui";
import { setRoomName, setRoomTopic, setRoomAvatar } from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import * as m from "$lib/paraglide/messages";
@@ -112,7 +112,11 @@
<!-- Avatar -->
<div class="flex flex-col items-center mb-6">
<div class="relative group">
<Avatar src={avatarPreview || room.avatarUrl} {name} size="xl" />
<MatrixAvatar
mxcUrl={avatarPreview || room.avatarUrl}
{name}
size="xl"
/>
<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"
>

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import { searchUsers, createDirectMessage } from '$lib/matrix';
import { toasts } from '$lib/stores/ui';
import { createLogger, getErrorMessage } from '$lib/utils/logger';
import { MatrixAvatar } from "$lib/components/ui";
import { searchUsers, createDirectMessage } from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import { createLogger, getErrorMessage } from "$lib/utils/logger";
const log = createLogger('matrix:dm');
const log = createLogger("matrix:dm");
interface Props {
onClose: () => void;
@@ -13,15 +13,17 @@
let { onClose, onDMCreated }: Props = $props();
let searchQuery = $state('');
let searchResults = $state<Array<{ userId: string; displayName: string; avatarUrl: string | null }>>([]);
let searchQuery = $state("");
let searchResults = $state<
Array<{ userId: string; displayName: string; avatarUrl: string | null }>
>([]);
let isSearching = $state(false);
let isCreating = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
function handleSearch() {
if (searchTimeout) clearTimeout(searchTimeout);
if (!searchQuery.trim()) {
searchResults = [];
return;
@@ -32,7 +34,7 @@
try {
searchResults = await searchUsers(searchQuery);
} catch (e) {
log.error('Search failed', { error: e });
log.error("Search failed", { error: e });
} finally {
isSearching = false;
}
@@ -43,19 +45,19 @@
isCreating = true;
try {
const roomId = await createDirectMessage(userId);
toasts.success('Direct message started!');
toasts.success("Direct message started!");
onDMCreated(roomId);
onClose();
} catch (e: unknown) {
log.error('Failed to create DM', { error: e });
toasts.error(getErrorMessage(e, 'Failed to start direct message'));
log.error("Failed to create DM", { error: e });
toasts.error(getErrorMessage(e, "Failed to start direct message"));
} finally {
isCreating = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (e.key === "Escape") {
onClose();
}
}
@@ -82,7 +84,13 @@
<div class="mb-4">
<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" />
<path d="m21 21-4.35-4.35" />
</svg>
@@ -101,9 +109,24 @@
<div class="max-h-64 overflow-y-auto">
{#if isSearching}
<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">
<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
class="w-6 h-6 animate-spin mx-auto mb-2"
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>
Searching...
</div>
@@ -116,9 +139,15 @@
onclick={() => handleStartDM(user.userId)}
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">
<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>
</div>
</button>

View File

@@ -6,21 +6,30 @@
let { userNames }: Props = $props();
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 === 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`;
}
</script>
{#if userNames.length > 0}
<div class="flex items-center gap-2 px-4 py-2 text-sm text-light/50">
<!-- Animated dots -->
<div class="flex gap-1">
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 0ms"></span>
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 150ms"></span>
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 300ms"></span>
<div class="flex items-center gap-1.5 px-5 py-1.5 text-[11px] text-light/35">
<div class="flex gap-0.5">
<span
class="w-1.5 h-1.5 bg-light/35 rounded-full animate-bounce"
style="animation-delay: 0ms"
></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>
<span>{formatTypingText(userNames)}</span>
</div>

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import { createDirectMessage } from '$lib/matrix';
import { userPresence } from '$lib/stores/matrix';
import { toasts } from '$lib/stores/ui';
import { createLogger } from '$lib/utils/logger';
import { MatrixAvatar } from "$lib/components/ui";
import { createDirectMessage } from "$lib/matrix";
import { userPresence } from "$lib/stores/matrix";
import { toasts } from "$lib/stores/ui";
import { createLogger } from "$lib/utils/logger";
const log = createLogger('matrix:profile');
import type { RoomMember } from '$lib/matrix/types';
const log = createLogger("matrix:profile");
import type { RoomMember } from "$lib/matrix/types";
interface Props {
member: RoomMember;
@@ -19,16 +19,18 @@
let isStartingDM = $state(false);
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({
online: { text: 'Online', color: 'text-green-400' },
offline: { text: 'Offline', color: 'text-gray-400' },
unavailable: { text: 'Away', color: 'text-yellow-400' },
}[presence]);
const presenceLabel = $derived(
{
online: { text: "Online", color: "text-green-400" },
offline: { text: "Offline", color: "text-gray-400" },
unavailable: { text: "Away", color: "text-yellow-400" },
}[presence],
);
async function handleStartDM() {
isStartingDM = true;
@@ -38,16 +40,28 @@
onStartDM?.(roomId);
onClose();
} catch (e) {
log.error('Failed to start DM', { error: e });
toasts.error('Failed to start direct message');
log.error("Failed to start DM", { error: e });
toasts.error("Failed to start direct message");
} finally {
isStartingDM = false;
}
}
function getRoleBadge(powerLevel: number): { 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: '🛡️' };
function getRoleBadge(
powerLevel: number,
): { 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;
}
@@ -63,7 +77,7 @@
aria-labelledby="profile-title"
tabindex="-1"
onclick={onClose}
onkeydown={(e) => e.key === 'Enter' && onClose()}
onkeydown={(e) => e.key === "Enter" && onClose()}
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
@@ -79,7 +93,13 @@
onclick={onClose}
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="6" y1="6" x2="18" y2="18" />
</svg>
@@ -89,26 +109,46 @@
<!-- Avatar -->
<div class="flex justify-center -mt-12 relative z-10">
<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>
<!-- Content -->
<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>
<!-- Status -->
<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>
</div>
<!-- Role badge -->
{#if roleBadge}
<div class="mt-3">
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs {roleBadge.color}">
{roleBadge.icon} {roleBadge.label}
<span
class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs {roleBadge.color}"
>
{roleBadge.icon}
{roleBadge.label}
</span>
</div>
{/if}
@@ -120,10 +160,18 @@
onclick={handleStartDM}
disabled={isStartingDM}
>
<svg 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
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>
{isStartingDM ? 'Starting...' : 'Send Message'}
{isStartingDM ? "Starting..." : "Send Message"}
</button>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import { MatrixAvatar } from "$lib/components/ui";
import { getReadReceiptsForEvent } from "$lib/matrix";
import type { Message } from "$lib/matrix/types";
import { formatTime } from "./utils";
@@ -66,8 +66,8 @@
</script>
<div
class="group relative px-4 py-0.5 hover:bg-light/5 transition-colors {message.isPending
? 'opacity-50'
class="group relative px-5 py-0.5 hover:bg-light/[0.02] transition-colors {message.isPending
? 'opacity-40'
: ''}"
onmouseenter={() => (showActions = true)}
onmouseleave={() => (showActions = false)}
@@ -77,32 +77,22 @@
<!-- Reply preview -->
{#if replyPreview && message.replyTo}
<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!)}
>
<div class="flex items-center gap-1.5">
<div class="flex shrink-0">
<Avatar
src={replyPreview.senderAvatar}
name={replyPreview.senderName}
size="xs"
/>
</div>
<span class="text-light/70 font-medium">{replyPreview.senderName}</span>
</div>
<span class="text-light/50 truncate max-w-xs">
<div class="w-0.5 h-3 bg-primary/40 rounded-full shrink-0"></div>
<MatrixAvatar
mxcUrl={replyPreview.senderAvatar}
name={replyPreview.senderName}
size="xs"
/>
<span class="text-light/50 font-body">{replyPreview.senderName}</span>
<span class="text-light/30 truncate max-w-xs">
{#if replyPreview.hasAttachment}
<svg
class="w-3 h-3 inline mr-0.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span
class="material-symbols-rounded align-middle mr-0.5"
style="font-size: 12px;">image</span
>
<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}
{replyPreview.content}
</span>
@@ -110,11 +100,11 @@
{/if}
{#if isGrouped}
<!-- Grouped message (same sender, close in time) -->
<div class="flex gap-4">
<div class="w-10 shrink-0 flex items-center justify-center">
<!-- Grouped message -->
<div class="flex gap-3">
<div class="w-9 shrink-0 flex items-center justify-center">
<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)}
</span>
@@ -136,21 +126,23 @@
</div>
</div>
{:else}
<!-- Full message with avatar - mt-4 creates gap between message groups -->
<div class="flex gap-4 mt-4 first:mt-0">
<div class="w-10 shrink-0">
<Avatar
src={message.senderAvatar}
<!-- Full message with avatar -->
<div class="flex gap-3 mt-3 first:mt-0">
<div class="w-9 shrink-0 pt-0.5">
<MatrixAvatar
mxcUrl={message.senderAvatar}
name={message.senderName}
size="md"
size="sm"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 mb-0.5">
<span class="font-semibold text-light hover:underline cursor-pointer">
<div class="flex items-baseline gap-2 mb-px">
<span
class="text-[13px] font-heading text-white hover:underline cursor-pointer"
>
{message.senderName}
</span>
<span class="text-xs text-light/40">
<span class="text-[10px] text-light/25 select-none">
{formatTime(message.timestamp)}
</span>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import Twemoji from '$lib/components/ui/Twemoji.svelte';
import EmojiPicker from '$lib/components/ui/EmojiPicker.svelte';
import Twemoji from "$lib/components/ui/Twemoji.svelte";
import EmojiPicker from "$lib/components/ui/EmojiPicker.svelte";
interface Props {
isOwnMessage?: boolean;
@@ -26,171 +26,152 @@
onPin,
}: Props = $props();
const quickReactions = ['👍', '❤️', '😂'];
const quickReactions = ["👍", "❤️", "😂"];
let showEmojiPicker = $state(false);
let showContextMenu = $state(false);
let menuPosition = $state({ x: 0, y: 0 });
let menuRef: HTMLDivElement | undefined = $state();
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;
showEmojiPicker = false;
}
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;
showContextMenu = false;
}
</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 -->
{#each quickReactions as emoji}
<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)}
title="React with {emoji}"
>
<Twemoji {emoji} size={18} />
<Twemoji {emoji} size={16} />
</button>
{/each}
<!-- Emoji picker -->
<!-- Emoji picker trigger -->
<div class="relative">
<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}
title="Add reaction"
>
<svg class="w-4 h-4" 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>
<span class="material-symbols-rounded" style="font-size: 16px;"
>add_reaction</span
>
</button>
{#if showEmojiPicker}
<EmojiPicker
position={menuPosition}
onSelect={(emoji) => onReact?.(emoji)}
onClose={() => (showEmojiPicker = false)}
/>
<div class="absolute bottom-full right-0 mb-2 z-50">
<EmojiPicker
onSelect={(emoji) => {
onReact?.(emoji);
showEmojiPicker = false;
}}
onClose={() => (showEmojiPicker = false)}
/>
</div>
{/if}
</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
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?.()}
title="Reply"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9,17 4,12 9,7" />
<path d="M20,18 v-2 a4,4 0 0,0 -4,-4 H4" />
</svg>
<span class="material-symbols-rounded" style="font-size: 16px;">reply</span>
</button>
<!-- Edit button (own messages only) -->
<!-- Edit (own messages only) -->
{#if isOwnMessage}
<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?.()}
title="Edit"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<span class="material-symbols-rounded" style="font-size: 16px;">edit</span
>
</button>
{/if}
<!-- Context menu -->
<div class="relative">
<!-- More options -->
<div class="relative" bind:this={menuRef}>
<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}
title="More options"
title="More"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</svg>
<span class="material-symbols-rounded" style="font-size: 16px;"
>more_horiz</span
>
</button>
{#if showContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed bg-dark border border-light/20 rounded-lg shadow-xl py-1 z-[100] min-w-[180px]"
style="left: {menuPosition.x}px; top: {menuPosition.y}px;"
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]"
onclick={(e) => e.stopPropagation()}
>
<button
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
onclick={() => { onPin?.(); showContextMenu = false; }}
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;
}}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill={isPinned ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2">
<path d="M12 2L12 12M12 12L8 8M12 12L16 8" transform="rotate(45 12 12)" />
<line x1="5" y1="12" x2="19" y2="12" transform="rotate(45 12 12)" />
</svg>
{isPinned ? 'Unpin' : 'Pin'} message
<span class="material-symbols-rounded" style="font-size: 16px;"
>{isPinned ? "push_pin" : "push_pin"}</span
>
{isPinned ? "Unpin" : "Pin"} message
</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"
onclick={() => { navigator.clipboard.writeText(messageContent); showContextMenu = false; }}
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;
}}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
<span class="material-symbols-rounded" style="font-size: 16px;"
>content_copy</span
>
Copy text
</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"
onclick={() => { navigator.clipboard.writeText(messageEventId); showContextMenu = false; }}
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;
}}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<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>
<span class="material-symbols-rounded" style="font-size: 16px;"
>link</span
>
Copy message ID
</button>
{#if isOwnMessage}
<div class="h-px bg-light/10 my-1"></div>
<div class="h-px bg-light/5 my-1"></div>
<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"
onclick={() => { onDelete?.(); showContextMenu = false; }}
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;
}}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6" />
<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>
<span class="material-symbols-rounded" style="font-size: 16px;"
>delete</span
>
Delete message
</button>
{/if}

View File

@@ -56,7 +56,7 @@
</script>
{#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]}
{@const hasReacted = userMap.has(currentUserId)}
{@const reactionEventId = getUserReactionEventId(emoji)}

View File

@@ -15,7 +15,7 @@
{#if receipts.length > 0}
<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(', ')}"
>
<span class="text-xs text-light/40 mr-1">Read by</span>

View File

@@ -742,18 +742,28 @@
</div>
<div class="flex flex-col gap-3">
<!-- Chat toggle hidden for now -->
<!-- Chat toggle disabled until fully developed -->
<!-- <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"
>
<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>
<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>
<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

View File

@@ -8,6 +8,14 @@
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 sizes = {
@@ -35,11 +43,12 @@
</script>
<div class="relative inline-block shrink-0">
{#if src}
{#if showImg}
<img
{src}
alt={name}
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0"
onerror={() => (imgFailed = true)}
/>
{:else}
<div

View File

@@ -9,45 +9,40 @@
interface Props {
onSelect: (emoji: string) => 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 activeCategory = $state("frequent");
let pickerRef: HTMLDivElement | null = $state(null);
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(() => {
adjustedPosition = { x: position.x, y: position.y };
});
if (!position || !pickerRef) return;
// Adjust position to stay within viewport
$effect(() => {
if (pickerRef) {
const rect = pickerRef.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const rect = pickerRef.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let newX = position.x;
let newY = position.y;
let newX = position.x;
let newY = position.y;
// Adjust horizontal position
if (newX + rect.width > viewportWidth - 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 + rect.width > viewportWidth - 10) {
newX = viewportWidth - rect.width - 10;
}
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
@@ -100,8 +95,12 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={pickerRef}
class="fixed bg-dark border border-light/20 rounded-xl shadow-2xl z-[100] w-[352px] overflow-hidden"
style="left: {adjustedPosition.x}px; top: {adjustedPosition.y}px;"
class="{isInline
? ''
: '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()}
onkeydown={(e) => e.stopPropagation()}
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">
import { getTwemojiUrl } from '$lib/utils/twemoji';
import { getCachedTwemojiUrl } from "$lib/utils/twemoji";
interface Props {
emoji: string;
@@ -7,9 +7,9 @@
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>
<img
@@ -18,4 +18,5 @@
class="inline-block align-text-bottom {className}"
style="width: {size}px; height: {size}px;"
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 Select } from './Select.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 Card } from './Card.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 => ({
roomId: room.roomId,
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 => ({
userId: 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',
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) => ({
userId: 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) {
log.error('User search failed', { error: e });
@@ -1045,7 +1045,7 @@ export function getReadReceiptsForEvent(roomId: string, eventId: string): Array<
readers.push({
userId: 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
const member = room.getMember(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
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 {
roomId: room.roomId,
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,
isDirect: room.getDMInviter() !== undefined,
isEncrypted: room.hasEncryptionStateEvent(),
@@ -441,7 +441,7 @@ function eventToMessage(event: MatrixEvent, room?: Room | null, skipCache = fals
const member = roomObj.getMember(sender);
if (member) {
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);
// 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());
userPresence.set(new Map());
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
* Includes in-memory blob URL cache for instant re-renders
*/
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
*/
@@ -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 {
// Remove variation selector (FE0F) as Twemoji uses base codepoints
const codePoint = [...emoji]
function emojiToCodepoint(emoji: string): string {
return [...emoji]
.filter((char) => char.codePointAt(0) !== 0xfe0f)
.map((char) => char.codePointAt(0)?.toString(16))
.filter(Boolean)
.join('-')
.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(),
icon: "celebration",
},
// Chat disabled for now (feature_chat still in DB)
// Chat disabled until fully developed
// ...(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.