fix(gallery): paginate media, activity log and export on detached timeline sets — never the live timeline (#163)
RoomTimeline renders a numeric index window into the live timeline's event arrays; SDK back-pagination prepends, so any side panel calling paginateEventTimeline(room.getLiveTimeline()) shifted the visible messages into the past on the next render and broke at-bottom tracking. New utils/detachedTimeline.ts builds a timeline set that mirrors the already-loaded history and paginates independently: a room-registered filtered set (server-side contains_url / types filter) when the filter is usable, else a private EventTimelineSet seeded from the live timeline. useRoomMediaTimeline wraps it for the gallery (live events + redactions handled); RoomActivityLog uses a type filter (safe in encrypted rooms); ExportRoomHistory pages a private set so a full export no longer parks thousands of events in the live timeline. Verified with Playwright against a local Synapse in a 400-message plain room and a 200-message encrypted room: timeline stays at the bottom through gallery pages, activity load-more and a full export; live messages keep auto-scrolling; all media found in both rooms. Also adds scripts/dev-homeserver.sh + scripts/dev-seed.py (local throwaway Synapse for driving the real UI) and documents them. Closes #163 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local throwaway Synapse for driving the real UI (Playwright / a browser)
|
||||
# against a homeserver you control — open registration, no rate limits,
|
||||
# SQLite, media served. Everything lives in .dev-homeserver/ (gitignored).
|
||||
#
|
||||
# scripts/dev-homeserver.sh start # install (first run) + start on :8008
|
||||
# scripts/dev-homeserver.sh stop
|
||||
# scripts/dev-homeserver.sh reset # wipe the database and media
|
||||
# python3 scripts/dev-seed.py 400 # alice/bob + "Busy Room" with images
|
||||
#
|
||||
# Then `npm start` and log in at http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/
|
||||
# as alice / password123.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DIR="$ROOT/.dev-homeserver"
|
||||
VENV="$DIR/venv"
|
||||
CFG="$DIR/homeserver.yaml"
|
||||
PIDFILE="$DIR/synapse.pid"
|
||||
|
||||
install() {
|
||||
mkdir -p "$DIR"
|
||||
if [ ! -x "$VENV/bin/python" ]; then
|
||||
python3 -m venv --without-pip "$VENV"
|
||||
curl -sS https://bootstrap.pypa.io/get-pip.py -o "$DIR/get-pip.py"
|
||||
"$VENV/bin/python" "$DIR/get-pip.py" -q
|
||||
"$VENV/bin/pip" install -q matrix-synapse
|
||||
fi
|
||||
if [ ! -f "$CFG" ]; then
|
||||
(cd "$DIR" && "$VENV/bin/python" -m synapse.app.homeserver \
|
||||
--server-name localhost --config-path homeserver.yaml --generate-config --report-stats=no >/dev/null)
|
||||
# the generated listener only serves `client`; add media + open the door
|
||||
python3 - "$CFG" <<'PY'
|
||||
import sys, re
|
||||
p = sys.argv[1]; s = open(p).read()
|
||||
s = s.replace(" - client\n", " - client\n - media\n", 1)
|
||||
s += """
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
rc_message: { per_second: 1000, burst_count: 10000 }
|
||||
rc_registration: { per_second: 1000, burst_count: 10000 }
|
||||
rc_login: { address: { per_second: 1000, burst_count: 10000 }, account: { per_second: 1000, burst_count: 10000 }, failed_attempts: { per_second: 1000, burst_count: 10000 } }
|
||||
rc_joins: { local: { per_second: 1000, burst_count: 10000 }, remote: { per_second: 1000, burst_count: 10000 } }
|
||||
rc_presence: { per_user: { per_second: 1000, burst_count: 10000 } }
|
||||
max_upload_size: 50M
|
||||
suppress_key_server_warning: true
|
||||
"""
|
||||
open(p, "w").write(s)
|
||||
PY
|
||||
fi
|
||||
}
|
||||
|
||||
start() {
|
||||
install
|
||||
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||
echo "already running (pid $(cat "$PIDFILE"))"; return
|
||||
fi
|
||||
(cd "$DIR" && setsid nohup "$VENV/bin/python" -m synapse.app.homeserver --config-path homeserver.yaml \
|
||||
> synapse.log 2>&1 < /dev/null & echo $! > "$PIDFILE")
|
||||
for _ in $(seq 1 40); do
|
||||
sleep 1
|
||||
curl -sf http://127.0.0.1:8008/_matrix/client/versions >/dev/null && { echo "synapse up on http://localhost:8008"; return; }
|
||||
done
|
||||
echo "synapse did not come up — see $DIR/synapse.log" >&2; exit 1
|
||||
}
|
||||
|
||||
stop() {
|
||||
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null || true; rm -f "$PIDFILE"; echo stopped; fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start) start ;;
|
||||
stop) stop ;;
|
||||
reset) stop; rm -f "$DIR"/homeserver.db* ; rm -rf "$DIR/media_store"; echo "database wiped"; ;;
|
||||
*) echo "usage: $0 start|stop|reset" >&2; exit 2 ;;
|
||||
esac
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed the local dev homeserver (scripts/dev-homeserver.sh) with two users
|
||||
and a busy unencrypted room: alice + bob, "Busy Room", N messages, one image
|
||||
every 10th message. Idempotent for the users; every run creates a new room.
|
||||
|
||||
python3 scripts/dev-seed.py [N=400]
|
||||
"""
|
||||
import json, struct, sys, time, urllib.error, urllib.parse, urllib.request, zlib
|
||||
|
||||
HS = "http://127.0.0.1:8008"
|
||||
PASSWORD = "password123"
|
||||
|
||||
|
||||
def req(method, path, data=None, token=None, raw=None, ctype="application/json"):
|
||||
headers = {"Content-Type": ctype}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
body = raw if raw is not None else (json.dumps(data).encode() if data is not None else None)
|
||||
r = urllib.request.Request(HS + path, data=body, headers=headers, method=method)
|
||||
return json.load(urllib.request.urlopen(r))
|
||||
|
||||
|
||||
def register_or_login(user):
|
||||
try:
|
||||
return req("POST", "/_matrix/client/v3/register",
|
||||
{"username": user, "password": PASSWORD, "auth": {"type": "m.login.dummy"}})
|
||||
except urllib.error.HTTPError:
|
||||
return req("POST", "/_matrix/client/v3/login",
|
||||
{"type": "m.login.password", "identifier": {"type": "m.id.user", "user": user}, "password": PASSWORD})
|
||||
|
||||
|
||||
def png(w, h, rgb):
|
||||
raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h))
|
||||
def chunk(t, d):
|
||||
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)
|
||||
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
|
||||
|
||||
|
||||
def main():
|
||||
n = int(sys.argv[1]) if len(sys.argv) > 1 else 400
|
||||
alice, bob = register_or_login("alice"), register_or_login("bob")
|
||||
ta, tb = alice["access_token"], bob["access_token"]
|
||||
room = req("POST", "/_matrix/client/v3/createRoom",
|
||||
{"name": "Busy Room", "preset": "public_chat", "visibility": "public"}, ta)["room_id"]
|
||||
req("POST", f"/_matrix/client/v3/join/{urllib.parse.quote(room)}", {}, tb)
|
||||
txn = int(time.time() * 1000)
|
||||
for i in range(n):
|
||||
tok = ta if i % 2 == 0 else tb
|
||||
if i % 10 == 0:
|
||||
data = png(64, 48, ((i * 37) % 256, (i * 91) % 256, (i * 17) % 256))
|
||||
up = req("POST", f"/_matrix/media/v3/upload?filename=img{i}.png", token=tok, raw=data, ctype="image/png")
|
||||
content = {"msgtype": "m.image", "body": f"img{i}.png", "url": up["content_uri"],
|
||||
"info": {"mimetype": "image/png", "w": 64, "h": 48, "size": len(data)}}
|
||||
else:
|
||||
content = {"msgtype": "m.text", "body": f"message #{i}"}
|
||||
txn += 1
|
||||
req("PUT", f"/_matrix/client/v3/rooms/{urllib.parse.quote(room)}/send/m.room.message/{txn}", content, tok)
|
||||
print(json.dumps({"room": room, "alice": alice["user_id"], "bob": bob["user_id"], "count": n}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user