Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5c54166a4 | |||
| f438018400 | |||
| 89003f71a4 | |||
| b364c53c61 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.21.10",
|
||||
"version": "0.21.11",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
||||
// letter circle. Use this everywhere the app needs to render a profile.
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
||||
|
||||
interface Props {
|
||||
@@ -27,7 +29,13 @@ export function Avatar({
|
||||
loading = 'lazy',
|
||||
}: Props) {
|
||||
const effectiveUrl = useCachedAvatarUrl(url);
|
||||
if (effectiveUrl) {
|
||||
// If the image URL is non-empty but unreachable (e.g. the storage object is
|
||||
// missing / 404s), the bare <img> would render broken with no fallback.
|
||||
// Track a load error and degrade to the letter circle instead. Reset on URL
|
||||
// change so a fresh, valid avatar is retried.
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => setFailed(false), [effectiveUrl]);
|
||||
if (effectiveUrl && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={effectiveUrl}
|
||||
@@ -35,6 +43,7 @@ export function Avatar({
|
||||
className={'shrink-0 rounded-full object-cover ' + className}
|
||||
draggable={false}
|
||||
loading={loading}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,21 +144,18 @@ export function ConversationPage() {
|
||||
byMessage: reactionsByMessage,
|
||||
toggle: toggleReaction,
|
||||
voteExclusive: votePoll,
|
||||
ready: reactionsReady,
|
||||
} = useMessageReactions(messageIds, session?.user.id);
|
||||
|
||||
// Deferred-reveal gate for MessageList: keep the list hidden until messages
|
||||
// AND their reactions (the main post-paint height changer) are loaded, so the
|
||||
// chat opens already-stable instead of flickering through the load cascade.
|
||||
// A 300 ms max-timeout ensures a slow/empty reactions fetch never hangs it.
|
||||
const [revealTimedOut, setRevealTimedOut] = useState(false);
|
||||
useEffect(() => {
|
||||
setRevealTimedOut(false);
|
||||
if (!id || loading || messages.length === 0) return;
|
||||
const tmo = window.setTimeout(() => setRevealTimedOut(true), 300);
|
||||
return () => window.clearTimeout(tmo);
|
||||
}, [id, loading, messages.length]);
|
||||
const listReady = !loading && messages.length > 0 && (reactionsReady || revealTimedOut);
|
||||
// Reveal gate for MessageList: as soon as messages exist (cache hit = first
|
||||
// render, so no spinner and no wait), let the list reveal. We deliberately do
|
||||
// NOT gate on reactions readiness: on a cache-hit chat switch the messages are
|
||||
// already present, and gating on the async reactions fetch held the list at
|
||||
// opacity:0 for up to 300ms and then "popped" it in — that was the residual
|
||||
// chat-switch flicker. Reaction chips stream in a beat later; because the list
|
||||
// is pinned to the bottom, their height growth re-pins with no visible jump.
|
||||
// MessageList still defers its own reveal a few frames until the row-height
|
||||
// measurement settles, so the list still appears already at the final bottom.
|
||||
const listReady = !loading && messages.length > 0;
|
||||
|
||||
const myId = session?.user.id;
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
# Incident fix (2026-06-02): after the netralax.cloud -> netralax.de move, every
|
||||
# storage object GET returned HTTP 500 with:
|
||||
# { "code": "ENODATA", "errno": 61, "message": "The extended attribute does not exist." }
|
||||
#
|
||||
# Root cause: the OBJECT BYTES were copied to the new server, but Supabase
|
||||
# Storage (supabase/storage-api:v1.48.26, file backend) keeps each object's
|
||||
# response metadata in Linux extended attributes (xattrs) on the version file:
|
||||
# user.supabase.content-type
|
||||
# user.supabase.cache-control
|
||||
# user.supabase.etag
|
||||
# The migration copy did not preserve xattrs, so storage's getObject() throws
|
||||
# ENODATA when it reads them. The OLD server is gone, so we cannot re-copy —
|
||||
# but every value we need is still in the database column storage.objects.metadata
|
||||
# (mimetype / cacheControl / eTag). This script reconstructs the missing xattrs
|
||||
# from that column. It is idempotent and only ADDS metadata xattrs; it never
|
||||
# touches object bytes.
|
||||
#
|
||||
# RUN ON THE NEW SERVER (needs /opt/supabase, docker, and root for setxattr):
|
||||
# sudo python3 04-restore-storage-xattrs.py # all buckets
|
||||
# sudo python3 04-restore-storage-xattrs.py --dry-run # show, change nothing
|
||||
# sudo python3 04-restore-storage-xattrs.py --bucket profile-avatars
|
||||
# After it finishes, no storage restart is needed (xattrs are read per request).
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
SUPABASE_DIR = "/opt/supabase"
|
||||
# Single-tenant self-hosted layout: <volume>/stub/stub/<bucket>/<name>/<version-file>
|
||||
STORAGE_ROOT = os.path.join(SUPABASE_DIR, "volumes/storage/stub/stub")
|
||||
|
||||
# DB metadata field -> (xattr name, default when the field is absent)
|
||||
XATTRS = [
|
||||
("mimetype", "user.supabase.content-type", "application/octet-stream"),
|
||||
("cacheControl", "user.supabase.cache-control", "no-cache"),
|
||||
("eTag", "user.supabase.etag", None), # None default => skip if missing
|
||||
]
|
||||
|
||||
|
||||
def fetch_objects():
|
||||
"""Return [(bucket_id, name, metadata_dict), ...] from storage.objects."""
|
||||
# Tab-separate so object names containing '|' can't break parsing.
|
||||
query = (
|
||||
"select bucket_id||chr(9)||name||chr(9)||coalesce(metadata::text,'{}') "
|
||||
"from storage.objects"
|
||||
)
|
||||
raw = subprocess.check_output(
|
||||
[
|
||||
"docker", "compose", "exec", "-T", "db",
|
||||
"psql", "-U", "postgres", "-d", "postgres", "-tAc", query,
|
||||
],
|
||||
cwd=SUPABASE_DIR,
|
||||
).decode()
|
||||
rows = []
|
||||
for line in raw.splitlines():
|
||||
line = line.rstrip("\r")
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("\t", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
bucket, name, meta = parts
|
||||
try:
|
||||
md = json.loads(meta) if meta else {}
|
||||
except json.JSONDecodeError:
|
||||
md = {}
|
||||
rows.append((bucket, name, md))
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--bucket", help="only this bucket (e.g. profile-avatars)")
|
||||
ap.add_argument("--dry-run", action="store_true", help="print, change nothing")
|
||||
args = ap.parse_args()
|
||||
|
||||
objects = fetch_objects()
|
||||
fixed = missing_dir = missing_file = 0
|
||||
|
||||
for bucket, name, md in objects:
|
||||
if args.bucket and bucket != args.bucket:
|
||||
continue
|
||||
objdir = os.path.join(STORAGE_ROOT, bucket, name)
|
||||
if not os.path.isdir(objdir):
|
||||
print("NO_DIR ", bucket, name)
|
||||
missing_dir += 1
|
||||
continue
|
||||
version_files = [
|
||||
os.path.join(objdir, f)
|
||||
for f in os.listdir(objdir)
|
||||
if os.path.isfile(os.path.join(objdir, f))
|
||||
]
|
||||
if not version_files:
|
||||
print("NO_FILE ", bucket, name)
|
||||
missing_file += 1
|
||||
continue
|
||||
for path in version_files:
|
||||
for field, xattr, default in XATTRS:
|
||||
value = md.get(field, default)
|
||||
if value is None:
|
||||
continue
|
||||
if args.dry_run:
|
||||
print(f" would set {xattr}={value!r} on {path}")
|
||||
else:
|
||||
os.setxattr(path, xattr, str(value).encode())
|
||||
fixed += 1
|
||||
print("OK ", bucket, name)
|
||||
|
||||
print(
|
||||
f"\n{'DRY-RUN: would fix' if args.dry_run else 'fixed'} {fixed} file(s); "
|
||||
f"{missing_dir} missing dir(s), {missing_file} empty object dir(s)."
|
||||
)
|
||||
if missing_dir or missing_file:
|
||||
print(
|
||||
"NOTE: objects with a missing dir/file have lost their bytes and "
|
||||
"cannot be recovered from xattrs — those are genuinely gone."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.geteuid() != 0 and "--dry-run" not in sys.argv:
|
||||
print("Re-run with sudo (setxattr needs root).", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
main()
|
||||
Reference in New Issue
Block a user