MEga push vol idk, chat function updates, docker fixes
This commit is contained in:
@@ -11,6 +11,7 @@ README.md
|
||||
node_modules
|
||||
build
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -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
|
||||
|
||||
38
Dockerfile
38
Dockerfile
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -268,8 +268,6 @@
|
||||
{onEdit}
|
||||
{onDelete}
|
||||
{onReply}
|
||||
{onLoadMore}
|
||||
isLoading={isLoadingMore}
|
||||
/>
|
||||
<TypingIndicator userNames={typingUsers} />
|
||||
<MessageInput
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -36,14 +36,12 @@
|
||||
|
||||
// Render emojis as Twemoji images for preview
|
||||
function renderEmojiPreview(text: string): string {
|
||||
// Escape HTML first
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
26
src/lib/components/ui/MatrixAvatar.svelte
Normal file
26
src/lib/components/ui/MatrixAvatar.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import Avatar from './Avatar.svelte';
|
||||
import { resolveAvatarUrl, getResolvedAvatarUrl, avatarCacheVersion } from '$lib/stores/avatarCache';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
mxcUrl?: string | null;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
status?: 'online' | 'offline' | 'away' | 'dnd' | null;
|
||||
}
|
||||
|
||||
let { name, mxcUrl = null, size = 'md', status = null }: Props = $props();
|
||||
|
||||
// Trigger resolution whenever mxcUrl changes
|
||||
$effect(() => {
|
||||
if (mxcUrl) resolveAvatarUrl(mxcUrl);
|
||||
});
|
||||
|
||||
// Re-derive when cache version bumps (reactive trigger)
|
||||
const resolvedSrc = $derived.by(() => {
|
||||
$avatarCacheVersion;
|
||||
return getResolvedAvatarUrl(mxcUrl);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Avatar {name} src={resolvedSrc} {size} {status} />
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
105
src/lib/stores/avatarCache.ts
Normal file
105
src/lib/stores/avatarCache.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Avatar Cache Store
|
||||
*
|
||||
* Resolves Matrix mxc:// avatar URLs through authenticated media endpoints
|
||||
* and caches the resulting blob URLs. Falls back to legacy URLs if auth fails.
|
||||
*
|
||||
* Usage:
|
||||
* import { resolveAvatarUrl, getAvatarUrl } from '$lib/stores/avatarCache';
|
||||
* // Start resolving (fire-and-forget)
|
||||
* resolveAvatarUrl(mxcUrl);
|
||||
* // Get current resolved URL (reactive via store)
|
||||
* const url = getAvatarUrl(mxcUrl);
|
||||
*/
|
||||
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { getClient, isClientInitialized } from '$lib/matrix/client';
|
||||
|
||||
// Resolved avatar URLs: mxc:// → blob URL or legacy HTTP URL
|
||||
const cache = new Map<string, string>();
|
||||
// In-flight fetches
|
||||
const pending = new Map<string, Promise<string | null>>();
|
||||
// Reactive store that increments on each cache update to trigger re-renders
|
||||
export const avatarCacheVersion = writable(0);
|
||||
|
||||
/**
|
||||
* Resolve an mxc:// URL to an authenticated blob URL.
|
||||
* Returns immediately if cached. Fetches in background otherwise.
|
||||
*/
|
||||
export function resolveAvatarUrl(mxcUrl: string | null | undefined): void {
|
||||
if (!mxcUrl || !mxcUrl.startsWith('mxc://') || cache.has(mxcUrl) || pending.has(mxcUrl)) return;
|
||||
if (!isClientInitialized()) return;
|
||||
|
||||
const client = getClient();
|
||||
const match = mxcUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/);
|
||||
if (!match) return;
|
||||
|
||||
const [, serverName, mediaId] = match;
|
||||
const accessToken = client.getAccessToken();
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
// Try authenticated thumbnail endpoint first (smaller, faster)
|
||||
const url = `${client.baseUrl}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=80&height=80&method=crop`;
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
cache.set(mxcUrl, blobUrl);
|
||||
avatarCacheVersion.update(v => v + 1);
|
||||
return blobUrl;
|
||||
}
|
||||
|
||||
// Fallback to legacy unauthenticated URL
|
||||
const legacyUrl = client.mxcUrlToHttp(mxcUrl, 80, 80, 'crop');
|
||||
if (legacyUrl) {
|
||||
cache.set(mxcUrl, legacyUrl);
|
||||
avatarCacheVersion.update(v => v + 1);
|
||||
return legacyUrl;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
// Last resort fallback
|
||||
const legacyUrl = client.mxcUrlToHttp(mxcUrl, 80, 80, 'crop');
|
||||
if (legacyUrl) {
|
||||
cache.set(mxcUrl, legacyUrl);
|
||||
avatarCacheVersion.update(v => v + 1);
|
||||
}
|
||||
return legacyUrl;
|
||||
} finally {
|
||||
pending.delete(mxcUrl);
|
||||
}
|
||||
})();
|
||||
|
||||
pending.set(mxcUrl, promise);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resolved URL for an avatar.
|
||||
* Returns cached blob URL, or null if not yet resolved.
|
||||
* Call resolveAvatarUrl() first to start the fetch.
|
||||
*/
|
||||
export function getResolvedAvatarUrl(mxcUrl: string | null | undefined): string | null {
|
||||
if (!mxcUrl) return null;
|
||||
// Non-mxc URLs (e.g. Supabase storage URLs) pass through directly
|
||||
if (!mxcUrl.startsWith('mxc://')) return mxcUrl;
|
||||
return cache.get(mxcUrl) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the avatar cache (e.g. on logout)
|
||||
*/
|
||||
export function clearAvatarCache(): void {
|
||||
// Revoke blob URLs to free memory
|
||||
for (const url of cache.values()) {
|
||||
if (url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
cache.clear();
|
||||
pending.clear();
|
||||
avatarCacheVersion.set(0);
|
||||
}
|
||||
@@ -217,7 +217,7 @@ function roomToSummary(room: Room, spaceChildMap: Map<string, string>): RoomSumm
|
||||
return {
|
||||
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(() => { });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
19
supabase/migrations/058_fix_handle_new_org_trigger.sql
Normal file
19
supabase/migrations/058_fix_handle_new_org_trigger.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Fix handle_new_org trigger: use auth.uid() instead of NEW.created_by
|
||||
-- The organizations table has no created_by column, so the trigger was inserting NULL
|
||||
-- as user_id into org_members, causing org creation to fail.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_org()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Add creator as owner (use auth.uid() since organizations has no created_by column)
|
||||
INSERT INTO public.org_members (org_id, user_id, role, joined_at)
|
||||
VALUES (NEW.id, auth.uid(), 'owner', now());
|
||||
-- Create default roles
|
||||
PERFORM public.create_default_org_roles(NEW.id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user