From ad724421629cf446205b02ab905c75009f6896be Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 15:46:07 -0400 Subject: [PATCH 01/68] =?UTF-8?q?feat:=20MSC3381=20polls=20=E2=80=94=20Pol?= =?UTF-8?q?lContent,=20PollCreator,=20timeline=20renderer,=20RoomInput=20b?= =?UTF-8?q?utton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/features/room/PollContent.tsx | 184 +++++++++++ src/app/features/room/PollCreator.tsx | 299 ++++++++++++++++++ src/app/features/room/RoomInput.tsx | 15 + .../timeline/useTimelineEventRenderer.tsx | 9 + src/app/hooks/usePollTally.test.ts | 109 +++++++ src/app/hooks/usePollTally.ts | 78 +++++ vitest.config.ts | 1 + 7 files changed, 695 insertions(+) create mode 100644 src/app/features/room/PollContent.tsx create mode 100644 src/app/features/room/PollCreator.tsx create mode 100644 src/app/hooks/usePollTally.test.ts create mode 100644 src/app/hooks/usePollTally.ts diff --git a/src/app/features/room/PollContent.tsx b/src/app/features/room/PollContent.tsx new file mode 100644 index 000000000..1a555ba08 --- /dev/null +++ b/src/app/features/room/PollContent.tsx @@ -0,0 +1,184 @@ +import type { MatrixEvent, Relations, Room } from '$types/matrix-sdk'; +import { useCallback, useEffect, useState } from 'react'; +import { Box, Button, Text, config } from 'folds'; +import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START } from 'matrix-js-sdk/lib/@types/polls'; +import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; +import { M_TEXT } from 'matrix-js-sdk/lib/@types/extensible_events'; +import { PollEvent as PollModelEvent } from 'matrix-js-sdk/lib/models/poll'; +import type { Poll } from 'matrix-js-sdk/lib/models/poll'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { tallyCounts } from '$hooks/usePollTally'; + +type PollContentProps = { + mEvent: MatrixEvent; + room: Room; +}; + +function getAnswerText(answer: PollAnswer): string { + const raw = answer as unknown as Record; + return ( + (raw[M_TEXT.name] as string | undefined) ?? + (raw[M_TEXT.altName] as string | undefined) ?? + '' + ); +} + +export function PollContent({ mEvent, room }: PollContentProps) { + const mx = useMatrixClient(); + const content = mEvent.getContent(); + + const pollRaw = ( + content[M_POLL_START.name] ?? content[M_POLL_START.altName] + ) as Record | undefined; + + const question: string = (() => { + if (!pollRaw) return '(Poll)'; + const q = pollRaw.question as Record | undefined; + if (!q) return '(Poll)'; + return ( + (q[M_TEXT.name] as string | undefined) ?? + (q[M_TEXT.altName] as string | undefined) ?? + '(Poll)' + ); + })(); + + const answers = (pollRaw?.answers as PollAnswer[] | undefined) ?? []; + const maxSelections = (pollRaw?.max_selections as number | undefined) ?? 1; + const kind = (pollRaw?.kind as string | undefined) ?? M_POLL_KIND_DISCLOSED.name; + const isDisclosed = + kind === M_POLL_KIND_DISCLOSED.name || kind === M_POLL_KIND_DISCLOSED.altName; + + const eventId = mEvent.getId() ?? ''; + const roomId = room.roomId; + const myUserId = mx.getUserId() ?? ''; + + const [relations, setRelations] = useState(undefined); + + useEffect(() => { + const roomWithPolls = room as unknown as { polls: Map }; + const poll = roomWithPolls.polls.get(eventId); + if (!poll) return; + + poll + .getResponses() + .then((rels) => setRelations(rels)) + .catch(() => {}); + + const onResponses = (rels: Relations) => setRelations(rels); + poll.on(PollModelEvent.Responses, onResponses); + return () => { + poll.off(PollModelEvent.Responses, onResponses); + }; + }, [room, eventId]); + + const tally = tallyCounts(answers, relations, myUserId, maxSelections); + + const handleVote = useCallback( + async (answerId: string) => { + const isSelected = tally.myAnswers.includes(answerId); + let newAnswers: string[]; + if (maxSelections === 1) { + newAnswers = isSelected ? [] : [answerId]; + } else { + newAnswers = isSelected + ? tally.myAnswers.filter((id) => id !== answerId) + : [...tally.myAnswers, answerId].slice(0, maxSelections); + } + + const voteContent: Record = { + [M_POLL_RESPONSE.name]: { answers: newAnswers }, + [M_POLL_RESPONSE.altName]: { answers: newAnswers }, + 'm.relates_to': { + rel_type: 'm.reference', + event_id: eventId, + }, + }; + + type SendEventContent = Parameters[3]; + await ( + mx as unknown as { + sendEvent( + roomId: string, + threadId: null, + eventType: string, + content: SendEventContent + ): Promise; + } + ).sendEvent( + roomId, + null, + M_POLL_RESPONSE.name, + voteContent as unknown as SendEventContent + ); + }, + [mx, roomId, eventId, tally.myAnswers, maxSelections] + ); + + return ( + + + {question} + + + {isDisclosed ? 'Poll · Results visible while voting' : 'Poll · Results hidden until ended'} + + + {answers.map((answer, idx) => { + const text = getAnswerText(answer); + const isSelected = tally.myAnswers.includes(answer.id); + const voteCount = tally.counts.get(answer.id) ?? 0; + const pct = + tally.totalVoters > 0 ? Math.round((voteCount / tally.totalVoters) * 100) : 0; + + return ( + + + + ); + })} + + {tally.totalVoters > 0 && ( + + {tally.totalVoters} {tally.totalVoters === 1 ? 'vote' : 'votes'} + + )} + + ); +} diff --git a/src/app/features/room/PollCreator.tsx b/src/app/features/room/PollCreator.tsx new file mode 100644 index 000000000..f26201a46 --- /dev/null +++ b/src/app/features/room/PollCreator.tsx @@ -0,0 +1,299 @@ +import { useCallback, useRef, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Dialog, + Header, + Icon, + IconButton, + Icons, + Input, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Switch, + Text, + config, +} from 'folds'; +import { PollStartEvent } from 'matrix-js-sdk/lib/extensible_events_v1/PollStartEvent'; +import { + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, +} from 'matrix-js-sdk/lib/@types/polls'; +import type { Room } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; + + +const MIN_ANSWERS = 2; +const MAX_ANSWERS = 20; + +let answerIdSeed = 0; +function newId(): string { + answerIdSeed += 1; + return `a${answerIdSeed}`; +} + +type AnswerDraft = { id: string; text: string }; + +type PollCreatorProps = { + room: Room; + onClose: () => void; +}; + +export function PollCreator({ room, onClose }: PollCreatorProps) { + const mx = useMatrixClient(); + + const [question, setQuestion] = useState(''); + const [answers, setAnswers] = useState([ + { id: newId(), text: '' }, + { id: newId(), text: '' }, + ]); + const [multiSelect, setMultiSelect] = useState(false); + const [maxSelections, setMaxSelections] = useState(2); + const [disclosed, setDisclosed] = useState(true); + const [sending, setSending] = useState(false); + const [error, setError] = useState(); + + const lastInputRef = useRef(null); + + const handleAddAnswer = useCallback(() => { + if (answers.length >= MAX_ANSWERS) return; + setAnswers((prev) => [...prev, { id: newId(), text: '' }]); + requestAnimationFrame(() => lastInputRef.current?.focus()); + }, [answers.length]); + + const handleRemoveAnswer = useCallback( + (id: string) => { + if (answers.length <= MIN_ANSWERS) return; + setAnswers((prev) => prev.filter((a) => a.id !== id)); + }, + [answers.length] + ); + + const handleAnswerChange = useCallback((id: string, text: string) => { + setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, text } : a))); + }, []); + + const handleMultiSelectToggle = useCallback((v: boolean) => { + setMultiSelect(v); + if (v) setMaxSelections(2); + }, []); + + const handleSend = useCallback(async () => { + const q = question.trim(); + if (!q) { + setError('Please enter a question.'); + return; + } + const validAnswers = answers.map((a) => a.text.trim()).filter(Boolean); + if (validAnswers.length < MIN_ANSWERS) { + setError(`Please fill in at least ${MIN_ANSWERS} answer options.`); + return; + } + + const kind = disclosed ? M_POLL_KIND_DISCLOSED : M_POLL_KIND_UNDISCLOSED; + const maxSel = multiSelect ? Math.max(2, Math.min(maxSelections, validAnswers.length)) : 1; + const pollEvent = PollStartEvent.from(q, validAnswers, kind, maxSel); + const serialized = pollEvent.serialize(); + + setSending(true); + setError(undefined); + try { + type SendEventContent = Parameters[3]; + await ( + mx as unknown as { + sendEvent( + roomId: string, + threadId: null, + eventType: string, + content: SendEventContent + ): Promise; + } + ).sendEvent( + room.roomId, + null, + serialized.type, + serialized.content as unknown as SendEventContent + ); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send poll.'); + setSending(false); + } + }, [question, answers, multiSelect, maxSelections, disclosed, mx, room.roomId, onClose]); + + return ( + }> + + + +
+ + + Create Poll + + + + +
+ + + + + {/* Question */} + + Question + setQuestion((e.target as HTMLInputElement).value)} + maxLength={340} + /> + + + {/* Answers */} + + Options + {answers.map((ans, idx) => ( + + + handleAnswerChange(ans.id, (e.target as HTMLInputElement).value)} + maxLength={340} + /> + + handleRemoveAnswer(ans.id)} + variant="Surface" + size="300" + radii="300" + disabled={answers.length <= MIN_ANSWERS} + aria-label={`Remove option ${idx + 1}`} + > + + + + ))} + {answers.length < MAX_ANSWERS && ( + + )} + + + {/* Multi-select */} + + + + Allow multiple selections + + {multiSelect && ( + + Up to + { + const v = parseInt((e.target as HTMLInputElement).value, 10); + if (!Number.isNaN(v)) { + setMaxSelections(Math.max(2, Math.min(v, answers.length))); + } + }} + style={{ width: '4rem' }} + /> + + )} + + + {/* Disclosed toggle */} + + + + {disclosed ? 'Disclosed poll' : 'Undisclosed poll'} + + {disclosed + ? 'Results visible while voting' + : 'Results hidden until poll ends'} + + + + + {error && ( + + {error} + + )} + + + + {/* Footer */} + + + + + +
+
+
+
+ ); +} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 850cffb50..9e5385897 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -155,6 +155,7 @@ import type { } from './AudioMessageRecorder'; import { AudioMessageRecorder } from './AudioMessageRecorder'; import * as prefix from '$unstable/prefixes'; +import { PollCreator } from './PollCreator'; // Returns the event ID of the most recent non-reaction/non-edit event in a thread, // falling back to the thread root if no replies exist yet. @@ -379,6 +380,7 @@ export const RoomInput = forwardRef( ); const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState(); const [showSchedulePicker, setShowSchedulePicker] = useState(false); + const [pollCreatorOpen, setPollCreatorOpen] = useState(false); const [silentReply, setSilentReply] = useState(!mentionInReplies); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const isEncrypted = room.hasEncryptionStateEvent(); @@ -1577,6 +1579,16 @@ export const RoomInput = forwardRef( /> } > + setPollCreatorOpen(true)} + variant="SurfaceVariant" + size="300" + radii="300" + title="Create poll" + aria-label="Create poll" + > + + {!hideStickerBtn && ( ( }} /> )} + {pollCreatorOpen && ( + setPollCreatorOpen(false)} /> + )} ); } diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index b4d54d825..ce2096fc7 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -12,6 +12,7 @@ import type { } from '$types/matrix-sdk'; import type { IImageContent } from '$types/matrix/common'; import { NotificationCountType, RoomEvent, ThreadEvent, EventType } from '$types/matrix-sdk'; +import { M_POLL_START } from 'matrix-js-sdk/lib/@types/polls'; import type { SessionMembershipData } from '$types/matrix-sdk'; import type { HTMLReactParserOptions } from 'html-react-parser'; import type { Opts as LinkifyOpts } from 'linkifyjs'; @@ -56,6 +57,7 @@ import * as customHtmlCss from '$styles/CustomHtml.css'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import type { ForwardedMessageProps } from '$features/room/message'; import { EncryptedContent, Event, Message, Reactions } from '$features/room/message'; +import { PollContent } from '$features/room/PollContent'; import { useSableCosmetics } from '$hooks/useSableCosmetics'; @@ -644,6 +646,11 @@ export function useTimelineEventRenderer({ )} /> ); + if ( + type === (M_POLL_START.name as string) || + type === (M_POLL_START.altName as string) + ) + return ; if (type === (EventType.RoomMessage as string)) { const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); let editedNewContent: unknown; @@ -686,6 +693,8 @@ export function useTimelineEventRenderer({ ); }, + [M_POLL_START.name]: (_mEventId, mEvent) => , + [M_POLL_START.altName]: (_mEventId, mEvent) => , [EventType.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => { const { replyEventId: rawReplyEventId, threadRootId } = mEvent; const isThreadRel = isThreadRelationEvent(mEvent, threadRootId); diff --git a/src/app/hooks/usePollTally.test.ts b/src/app/hooks/usePollTally.test.ts new file mode 100644 index 000000000..db2fa2a05 --- /dev/null +++ b/src/app/hooks/usePollTally.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import type { Relations } from '$types/matrix-sdk'; +import type { MatrixEvent } from '$types/matrix-sdk'; +import { M_POLL_RESPONSE } from 'matrix-js-sdk/lib/@types/polls'; +import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; +import { tallyCounts } from '$hooks/usePollTally'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeRelations(events: Partial[]): Relations { + return { + getRelations: () => events as MatrixEvent[], + } as unknown as Relations; +} + +function makeVote( + sender: string, + answerIds: string[], + ts = 1000 +): Partial { + return { + getSender: () => sender, + getTs: () => ts, + getContent: (() => ({ + [M_POLL_RESPONSE.name]: { answers: answerIds }, + })) as MatrixEvent['getContent'], + }; +} + +const ANSWERS: PollAnswer[] = [ + { id: 'a', body: 'Option A', mimetype: 'text/plain' } as unknown as PollAnswer, + { id: 'b', body: 'Option B', mimetype: 'text/plain' } as unknown as PollAnswer, + { id: 'c', body: 'Option C', mimetype: 'text/plain' } as unknown as PollAnswer, +]; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('tallyCounts', () => { + it('returns zero counts and no voters for empty relations', () => { + const result = tallyCounts(ANSWERS, makeRelations([]), '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + expect(result.counts.get('a')).toBe(0); + expect(result.myAnswers).toEqual([]); + }); + + it('counts a single vote correctly', () => { + const rel = makeRelations([makeVote('@alice:example.com', ['a'])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.counts.get('a')).toBe(1); + expect(result.counts.get('b')).toBe(0); + expect(result.totalVoters).toBe(1); + }); + + it('only counts the last vote per user', () => { + const rel = makeRelations([ + makeVote('@alice:example.com', ['a'], 1000), + makeVote('@alice:example.com', ['b'], 2000), // later — should win + ]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.counts.get('a')).toBe(0); + expect(result.counts.get('b')).toBe(1); + expect(result.totalVoters).toBe(1); + }); + + it('ignores invalid answer IDs (not in poll answers)', () => { + const rel = makeRelations([makeVote('@alice:example.com', ['z'])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + expect([...result.counts.values()].every((v) => v === 0)).toBe(true); + }); + + it('tracks the current user vote in myAnswers', () => { + const rel = makeRelations([makeVote('@me:example.com', ['c'])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.myAnswers).toEqual(['c']); + }); + + it('supports multi-select up to max_selections', () => { + const rel = makeRelations([makeVote('@alice:example.com', ['a', 'b', 'c'])]); + // max_selections = 2 → only first 2 are kept + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 2); + expect(result.counts.get('a')).toBe(1); + expect(result.counts.get('b')).toBe(1); + expect(result.counts.get('c')).toBe(0); + }); + + it('handles null/undefined relations gracefully', () => { + const result = tallyCounts(ANSWERS, null, '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + }); + + it('counts multiple distinct voters independently', () => { + const rel = makeRelations([ + makeVote('@alice:example.com', ['a']), + makeVote('@bob:example.com', ['a']), + makeVote('@carol:example.com', ['b']), + ]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.counts.get('a')).toBe(2); + expect(result.counts.get('b')).toBe(1); + expect(result.totalVoters).toBe(3); + }); + + it('treats empty answers array as a spoil (abstain) — not counted in totalVoters', () => { + const rel = makeRelations([makeVote('@alice:example.com', [])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + }); +}); diff --git a/src/app/hooks/usePollTally.ts b/src/app/hooks/usePollTally.ts new file mode 100644 index 000000000..ff4e6d01d --- /dev/null +++ b/src/app/hooks/usePollTally.ts @@ -0,0 +1,78 @@ +import type { Relations } from '$types/matrix-sdk'; +import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; +import { M_POLL_RESPONSE } from 'matrix-js-sdk/lib/@types/polls'; + +export type PollTally = { + /** Map from answerId → vote count (deduplicated to last vote per user) */ + counts: Map; + /** Total number of users who cast at least one valid answer */ + totalVoters: number; + /** The current user's selected answer IDs (empty = not voted) */ + myAnswers: string[]; +}; + +/** + * Pure function — tallies poll votes from a Relations object. + * + * Rules per MSC3381: + * - Only the last response per user is counted. + * - Answers that don't exist in the poll's answer list are ignored (spoiled). + * - A response with an empty answers array is a deliberate spoil (abstain). + */ +export function tallyCounts( + answers: PollAnswer[], + relations: Relations | null | undefined, + myUserId: string, + maxSelections: number +): PollTally { + const validIds = new Set(answers.map((a) => a.id)); + const answerIds = answers.map((a) => a.id); + + // Map userId → their last response's answer IDs (already validated) + const lastVoteByUser = new Map(); + + const events = relations?.getRelations() ?? []; + + // Sort ascending so iterating gives chronological order; last write wins + const sorted = [...events].sort((a, b) => a.getTs() - b.getTs()); + + for (const event of sorted) { + const sender = event.getSender(); + if (!sender) continue; + + const content = event.getContent(); + // Support both stable (m.poll.response) and unstable (org.matrix.msc3381.poll.response) keys + const responsePart = + (content[M_POLL_RESPONSE.name] as { answers?: unknown } | undefined) ?? + (content[M_POLL_RESPONSE.altName] as { answers?: unknown } | undefined); + + if (!responsePart || !Array.isArray(responsePart.answers)) { + continue; + } + + const rawAnswers = responsePart.answers as unknown[]; + // Filter to only valid answer IDs; enforce max_selections limit + const validAnswers = (rawAnswers.filter((id) => typeof id === 'string' && validIds.has(id)) as string[]).slice( + 0, + Math.max(1, maxSelections) + ); + + lastVoteByUser.set(sender, validAnswers); + } + + const counts = new Map(answerIds.map((id) => [id, 0])); + let myAnswers: string[] = []; + + for (const [userId, selectedIds] of lastVoteByUser) { + for (const id of selectedIds) { + counts.set(id, (counts.get(id) ?? 0) + 1); + } + if (userId === myUserId) { + myAnswers = selectedIds; + } + } + + const totalVoters = Array.from(lastVoteByUser.values()).filter((ids) => ids.length > 0).length; + + return { counts, totalVoters, myAnswers }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 609b42c2b..7df3f36de 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ APP_VERSION: JSON.stringify('test'), BUILD_HASH: JSON.stringify(''), IS_RELEASE_TAG: JSON.stringify(false), + INJECTED_EXPERIMENT_FLAGS: JSON.stringify({}), }, test: { environment: 'jsdom', From e93a10ed74fe22f92d7d9a7c7d2d13353b7be5f5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 15:16:21 -0400 Subject: [PATCH 02/68] =?UTF-8?q?feat:=20developer=20tools=20=E2=80=94=20e?= =?UTF-8?q?xperiment=20flags=20panel=20+=20rotate=20Megolm=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add INJECTED_EXPERIMENT_FLAGS build constant (from VITE_FEATURE_* env vars) - Add experiments?: Record to ClientConfig type - Add useExperimentFlag() and setExperimentOverride() to useClientConfig - ExperimentsPanel: per-flag toggles with localStorage override and Reset button - Settings-level dev tools: render ExperimentsPanel when developerTools is on - Room-level dev tools: Rotate Megolm Session tile for encrypted rooms --- .../developer-tools/DevelopTools.tsx | 50 +++++++++ .../settings/developer-tools/DevelopTools.tsx | 6 ++ .../developer-tools/ExperimentsPanel.tsx | 102 ++++++++++++++++++ src/app/hooks/useClientConfig.ts | 29 +++++ src/ext.d.ts | 1 + vite.config.ts | 7 ++ 6 files changed, 195 insertions(+) create mode 100644 src/app/features/settings/developer-tools/ExperimentsPanel.tsx diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index d35e20da7..bb03e3bd4 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -63,6 +63,22 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { const [expandStateType, setExpandStateType] = useState(); const [openStateEvent, setOpenStateEvent] = useState(); const [composeEvent, setComposeEvent] = useState<{ type?: string; stateKey?: string }>(); + const [rotateSessionStatus, setRotateSessionStatus] = useState< + 'idle' | 'rotating' | 'done' | 'error' + >('idle'); + + const handleRotateSessions = useCallback(async () => { + const crypto = mx.getCrypto(); + if (!crypto) return; + setRotateSessionStatus('rotating'); + try { + await crypto.forceDiscardSession(room.roomId); + crypto.prepareToEncrypt(room); + setRotateSessionStatus('done'); + } catch { + setRotateSessionStatus('error'); + } + }, [mx, room]); const [expandAccountData, setExpandAccountData] = useState(false); const [accountDataType, setAccountDataType] = useState(); @@ -233,6 +249,40 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { /> )} + {developerTools && room.hasEncryptionStateEvent() && ( + + + + {rotateSessionStatus === 'rotating' ? 'Rotating…' : 'Rotate'} + + + } + /> + + )} {developerTools && ( diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 100119726..e15ae476f 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -15,6 +15,7 @@ import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; +import { ExperimentsPanel } from './ExperimentsPanel'; type DeveloperToolsProps = { requestBack?: () => void; @@ -127,6 +128,11 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} + {developerTools && ( + + + + )} diff --git a/src/app/features/settings/developer-tools/ExperimentsPanel.tsx b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx new file mode 100644 index 000000000..86dcec3b5 --- /dev/null +++ b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx @@ -0,0 +1,102 @@ +import { useState, useCallback } from 'react'; +import { Box, Text, Switch, Button } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { useClientConfig, setExperimentOverride } from '$hooks/useClientConfig'; + +const EXPERIMENT_OVERRIDE_PREFIX = 'sable_exp_'; + +function getActiveExperimentKeys(configExperiments?: Record): string[] { + const fromConfig = Object.keys(configExperiments ?? {}); + const fromBuild = Object.keys(INJECTED_EXPERIMENT_FLAGS); + const fromStorage = Object.keys(localStorage) + .filter((k) => k.startsWith(EXPERIMENT_OVERRIDE_PREFIX)) + .map((k) => k.slice(EXPERIMENT_OVERRIDE_PREFIX.length)); + + return Array.from(new Set([...fromConfig, ...fromBuild, ...fromStorage])).sort(); +} + +function getEffectiveValue( + key: string, + configExperiments?: Record +): { value: boolean; source: 'override' | 'config' | 'build' | 'default' } { + const lsValue = localStorage.getItem(`${EXPERIMENT_OVERRIDE_PREFIX}${key}`); + if (lsValue !== null) return { value: lsValue === 'true', source: 'override' }; + if (configExperiments && key in configExperiments) + return { value: configExperiments[key] ?? false, source: 'config' }; + if (key in INJECTED_EXPERIMENT_FLAGS) + return { value: INJECTED_EXPERIMENT_FLAGS[key] ?? false, source: 'build' }; + return { value: false, source: 'default' }; +} + +export function ExperimentsPanel() { + const config = useClientConfig(); + const [, forceUpdate] = useState(0); + const refresh = useCallback(() => forceUpdate((n) => n + 1), []); + + const keys = getActiveExperimentKeys(config.experiments); + + if (keys.length === 0) { + return ( + + Experiments + + No experiment flags are defined. Set VITE_FEATURE_* env vars at build time + or add an experiments field to config.json. + + + ); + } + + return ( + + Experiments + + Override experiment flags for this session. Changes are stored in localStorage and take + effect immediately on next render. + + + {keys.map((key) => { + const { value, source } = getEffectiveValue(key, config.experiments); + const hasOverride = source === 'override'; + return ( + + {hasOverride && ( + + )} + { + setExperimentOverride(key, v); + refresh(); + }} + /> + + } + /> + ); + })} + + + ); +} diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6cb2a9ad3..8a20602e5 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -50,6 +50,8 @@ export type ClientConfig = { themeCatalogApprovedHostPrefixes?: string[]; settingsDefaults?: Partial; + + experiments?: Record; }; const ClientConfigContext = createContext(null); @@ -76,3 +78,30 @@ export const clientAllowedServer = (clientConfig: ClientConfig, server: string): return homeserverList?.includes(server) === true; }; + +const EXPERIMENT_OVERRIDE_PREFIX = 'sable_exp_'; + +/** + * Returns the value of an experiment flag. Resolution order: + * 1. localStorage override (set by developer tools panel) + * 2. config.json `experiments` field (deploy-time config) + * 3. Build-time injected flags from VITE_FEATURE_* env vars + * 4. false (default) + */ +export function useExperimentFlag(key: string): boolean { + const config = useClientConfig(); + const lsKey = `${EXPERIMENT_OVERRIDE_PREFIX}${key}`; + const lsValue = localStorage.getItem(lsKey); + if (lsValue !== null) return lsValue === 'true'; + if (config.experiments && key in config.experiments) return config.experiments[key] ?? false; + return INJECTED_EXPERIMENT_FLAGS[key] ?? false; +} + +export function setExperimentOverride(key: string, value: boolean | null): void { + const lsKey = `${EXPERIMENT_OVERRIDE_PREFIX}${key}`; + if (value === null) { + localStorage.removeItem(lsKey); + } else { + localStorage.setItem(lsKey, value ? 'true' : 'false'); + } +} diff --git a/src/ext.d.ts b/src/ext.d.ts index 7ee0a20a8..cbded3fb2 100644 --- a/src/ext.d.ts +++ b/src/ext.d.ts @@ -3,6 +3,7 @@ declare const APP_VERSION: string; declare const BUILD_HASH: string; declare const IS_RELEASE_TAG: boolean; +declare const INJECTED_EXPERIMENT_FLAGS: Record; declare module 'browser-encrypt-attachment' { export interface EncryptedAttachmentInfo { diff --git a/vite.config.ts b/vite.config.ts index bfa79f67c..7efec1ff2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -50,6 +50,12 @@ const resolveBuildHash = (): string | undefined => { const appVersion = packageJson.version; const buildHash = resolveBuildHash(); +const injectedExperimentFlags = Object.fromEntries( + Object.entries(process.env) + .filter(([k]) => k.startsWith('VITE_FEATURE_')) + .map(([k, v]) => [k.slice('VITE_FEATURE_'.length).toLowerCase().replace(/_/g, '-'), v === 'true']) +); + const isReleaseTag = (() => { const envVal = process.env.VITE_IS_RELEASE_TAG; if (envVal !== undefined && envVal !== '') return envVal === 'true'; @@ -131,6 +137,7 @@ export default defineConfig(({ command }) => ({ APP_VERSION: JSON.stringify(appVersion), BUILD_HASH: JSON.stringify(buildHash ?? ''), IS_RELEASE_TAG: JSON.stringify(isReleaseTag), + INJECTED_EXPERIMENT_FLAGS: JSON.stringify(injectedExperimentFlags), }, resolve: { alias: { From f6abcc97db3b28f82994242c46f8dc24dadce2ee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 15:54:59 -0400 Subject: [PATCH 03/68] fix: add jsdom URL + INJECTED_EXPERIMENT_FLAGS to vitest config --- vitest.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index 7df3f36de..489cca78b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,6 +31,11 @@ export default defineConfig({ }, test: { environment: 'jsdom', + environmentOptions: { + jsdom: { + url: 'http://localhost/', + }, + }, globals: true, setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], From f041bb974ad3a9428a5591e8d0789b0c79f119f3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 15:10:46 -0400 Subject: [PATCH 04/68] feat(timeline): configurable message grouping threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'messageGroupingThreshold' setting (default: 2 minutes, matching upstream) that controls how long before a new sender block starts in the timeline. Exposed as a number input (1–60 min) in Messages settings. --- src/app/features/room/RoomTimeline.tsx | 2 + src/app/features/room/ThreadDrawer.tsx | 2 + src/app/features/settings/general/General.tsx | 51 +++++++++++++++++++ .../hooks/timeline/useProcessedTimeline.ts | 4 +- src/app/state/settings.ts | 2 + 5 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d63faa989..377831450 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -141,6 +141,7 @@ export function RoomTimeline({ const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); + const [messageGroupingThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [showBundledPreview] = useSetting(settingsAtom, 'bundledPreview'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); @@ -782,6 +783,7 @@ export function RoomTimeline({ readUptoEventId: readUptoEventIdRef.current, hideMembershipEvents, hideNickAvatarEvents, + messageGroupingThreshold, isReadOnly, hideMemberInReadOnly, }); diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index e8fb9ddd8..0dcfbf958 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -146,6 +146,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showTombstoneEvents] = useSetting(settingsAtom, 'showTombstoneEvents'); const [hideMemberInReadOnly] = useSetting(settingsAtom, 'hideMembershipInReadOnly'); + const [messageGroupingThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); const [showBundledPreview] = useSetting(settingsAtom, 'bundledPreview'); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const showClientUrlPreview = room.hasEncryptionStateEvent() @@ -258,6 +259,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra readUptoEventId: undefined, hideMembershipEvents: true, hideNickAvatarEvents: true, + messageGroupingThreshold, isReadOnly, hideMemberInReadOnly, }); diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 0af162280..5731634ba 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -866,6 +866,49 @@ function Calls() { ); } +function MessageGroupingThresholdInput() { + const [threshold, setThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); + const [inputValue, setInputValue] = useState(threshold.toString()); + + const handleChange: ChangeEventHandler = (evt) => { + const val = evt.target.value; + setInputValue(val); + + const parsed = Number.parseInt(val, 10); + if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 60) { + setThreshold(parsed); + } + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + setInputValue(threshold.toString()); + (evt.target as HTMLInputElement).blur(); + } + + if (isKeyHotkey('enter', evt)) { + (evt.target as HTMLInputElement).blur(); + } + }; + + return ( + + ); +} + function Messages() { const [hideMembershipEvents, setHideMembershipEvents] = useSetting( settingsAtom, @@ -942,6 +985,14 @@ function Messages() { } /> + + } + /> + Date: Sat, 2 May 2026 15:59:06 -0400 Subject: [PATCH 05/68] =?UTF-8?q?feat:=20presence=20auto-idle=20=E2=80=94?= =?UTF-8?q?=20set=20unavailable=20after=205=20min=20inactivity=20or=20tab?= =?UTF-8?q?=20hidden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/features/settings/general/General.tsx | 17 ++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 59 +++++++++++++++++++ src/app/state/settings.ts | 2 + 3 files changed, 78 insertions(+) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 5731634ba..ed568caba 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -419,6 +419,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [autoIdlePresence, setAutoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence'); const [mentionInReplies, setMentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); return ( @@ -476,6 +477,22 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { after={} /> + {sendPresence && ( + + + } + /> + + )} { // Classic sync: set_presence query param on every /sync poll. @@ -853,6 +854,64 @@ function PresenceFeature() { getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); }, [mx, sendPresence]); + // Auto-idle: set presence to unavailable after 5 minutes of inactivity or + // when the tab is hidden, and restore online on activity. + useEffect(() => { + if (!sendPresence || !autoIdlePresence) return undefined; + + const IDLE_TIMEOUT_MS = 5 * 60 * 1000; + let idleTimer: ReturnType | undefined; + let isIdle = false; + + const goOnline = () => { + if (!isIdle) return; + isIdle = false; + mx.setPresence({ presence: 'online' }).catch(() => {}); + }; + + const goIdle = () => { + if (isIdle) return; + isIdle = true; + mx.setPresence({ presence: 'unavailable' }).catch(() => {}); + }; + + const resetTimer = () => { + goOnline(); + clearTimeout(idleTimer); + idleTimer = setTimeout(goIdle, IDLE_TIMEOUT_MS); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + clearTimeout(idleTimer); + goIdle(); + } else { + resetTimer(); + } + }; + + const ACTIVITY_EVENTS: (keyof DocumentEventMap)[] = [ + 'mousemove', + 'keydown', + 'pointerdown', + 'scroll', + ]; + + ACTIVITY_EVENTS.forEach((e) => document.addEventListener(e, resetTimer, { passive: true })); + document.addEventListener('visibilitychange', handleVisibilityChange); + resetTimer(); + + return () => { + clearTimeout(idleTimer); + ACTIVITY_EVENTS.forEach((e) => document.removeEventListener(e, resetTimer)); + document.removeEventListener('visibilitychange', handleVisibilityChange); + // Restore online when feature is disabled + if (isIdle) { + mx.setPresence({ presence: 'online' }).catch(() => {}); + } + }; + }, [mx, sendPresence, autoIdlePresence]); + return null; } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 0fb86990a..3172ef8a4 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -126,6 +126,7 @@ export interface Settings { // Sable features! sendPresence: boolean; + autoIdlePresence: boolean; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; hideMembershipInReadOnly: boolean; @@ -249,6 +250,7 @@ export const defaultSettings: Settings = { // Sable features! sendPresence: true, + autoIdlePresence: true, mobileGestures: true, rightSwipeAction: RightSwipeAction.Reply, hideMembershipInReadOnly: true, From 6a181cd3629c1a57fd4b1bc54ea74168772cd46b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 16:03:10 -0400 Subject: [PATCH 06/68] =?UTF-8?q?feat:=20room=20message=20preview=20?= =?UTF-8?q?=E2=80=94=20last=20message=20shown=20under=20room=20name=20in?= =?UTF-8?q?=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/features/room-nav/RoomNavItem.tsx | 19 ++++ src/app/features/settings/general/General.tsx | 15 +++ src/app/hooks/useRoomLastMessagePreview.ts | 91 +++++++++++++++++++ src/app/state/settings.ts | 2 + 4 files changed, 127 insertions(+) create mode 100644 src/app/hooks/useRoomLastMessagePreview.ts diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index a372b975a..46a1d64b0 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -72,6 +72,7 @@ import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '$hooks/useLivekitSupport'; import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '$components/presence'; +import { useRoomLastMessagePreview } from '$hooks/useRoomLastMessagePreview'; import { RoomNavUser } from './RoomNavUser'; /** @@ -292,6 +293,9 @@ export function RoomNavItem({ const getRoomTopic = useRoomTopic(room); const roomTopic = direct ? ((customDMCards && getRoomTopic) ?? presence?.status) : undefined; + const [showRoomMessagePreview] = useSetting(settingsAtom, 'showRoomMessagePreview'); + const lastMessagePreview = useRoomLastMessagePreview(room, !direct); + const { navigateRoom } = useRoomNavigate(); const navigate = useNavigate(); const screenSize = useScreenSizeContext(); @@ -453,6 +457,21 @@ export function RoomNavItem({ {roomTopic} )} + {!roomTopic && showRoomMessagePreview && lastMessagePreview && ( + + {lastMessagePreview.senderDisplayName + ? `${lastMessagePreview.senderDisplayName}: ${lastMessagePreview.body}` + : lastMessagePreview.body} + + )} {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index ed568caba..e24da79ec 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -420,6 +420,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); const [autoIdlePresence, setAutoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence'); + const [showRoomMessagePreview, setShowRoomMessagePreview] = useSetting(settingsAtom, 'showRoomMessagePreview'); const [mentionInReplies, setMentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); return ( @@ -493,6 +494,20 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { /> )} + + + } + /> + MAX_PREVIEW_LEN ? `${text.slice(0, MAX_PREVIEW_LEN)}…` : text; +} + +function getEventPreviewBody(event: MatrixEvent): string | null { + const type = event.getType(); + const content = event.getContent() as Record; + + if (type === (EventType.RoomMessage as string)) { + const msgtype = content.msgtype as string | undefined; + const body = content.body as string | undefined; + if (!body) return null; + + if (msgtype === 'm.image') return '📷 Image'; + if (msgtype === 'm.video') return '🎬 Video'; + if (msgtype === 'm.audio') return '🎵 Audio'; + if (msgtype === 'm.file') return '📎 File'; + if (msgtype === 'm.sticker' || type === (EventType.Sticker as string)) return '🎭 Sticker'; + + return truncate(body); + } + + if (type === (EventType.Sticker as string)) return '🎭 Sticker'; + if (type === (EventType.RoomMessageEncrypted as string)) return '🔒 Encrypted message'; + + return null; +} + +type RoomLastMessagePreview = { + senderId: string; + senderDisplayName: string; + body: string; + ts: number; +} | null; + +export function useRoomLastMessagePreview( + room: Room, + includeSender: boolean +): RoomLastMessagePreview { + const [preview, setPreview] = useState(() => + buildPreview(room, includeSender) + ); + + useEffect(() => { + setPreview(buildPreview(room, includeSender)); + + const update = () => setPreview(buildPreview(room, includeSender)); + room.on(RoomEvent.Timeline, update); + room.on(RoomEvent.Redaction, update); + return () => { + room.off(RoomEvent.Timeline, update); + room.off(RoomEvent.Redaction, update); + }; + }, [room, includeSender]); + + return preview; +} + +function buildPreview(room: Room, includeSender: boolean): RoomLastMessagePreview { + const events = room.getLiveTimeline().getEvents(); + + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i]; + if (!event) continue; + if (event.isRedacted()) continue; + + const body = getEventPreviewBody(event); + if (!body) continue; + + const senderId = event.getSender() ?? ''; + const member = room.getMember(senderId); + const senderDisplayName = includeSender + ? (member?.name ?? senderId.split(':')[0]?.slice(1) ?? senderId) + : ''; + + return { + senderId, + senderDisplayName, + body, + ts: event.getTs(), + }; + } + + return null; +} diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 3172ef8a4..cd580e9e7 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -127,6 +127,7 @@ export interface Settings { // Sable features! sendPresence: boolean; autoIdlePresence: boolean; + showRoomMessagePreview: boolean; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; hideMembershipInReadOnly: boolean; @@ -251,6 +252,7 @@ export const defaultSettings: Settings = { // Sable features! sendPresence: true, autoIdlePresence: true, + showRoomMessagePreview: true, mobileGestures: true, rightSwipeAction: RightSwipeAction.Reply, hideMembershipInReadOnly: true, From 4b87d501f4910c8a8ef49e1718c363f55c5062b5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 16:10:58 -0400 Subject: [PATCH 07/68] =?UTF-8?q?feat:=20message=20bookmarks=20=E2=80=94?= =?UTF-8?q?=20bookmark=20events=20via=20moe.sable.app.bookmarks=20account?= =?UTF-8?q?=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/features/bookmarks/BookmarksPanel.tsx | 193 ++++++++++++++++++ src/app/features/room/message/Message.tsx | 38 ++++ src/app/hooks/useBookmarks.ts | 59 ++++++ src/app/pages/Router.tsx | 2 + src/app/pages/client/SidebarNav.tsx | 2 + src/app/pages/client/sidebar/BookmarksTab.tsx | 20 ++ src/app/pages/client/sidebar/index.ts | 1 + src/app/state/bookmarksPanelAtom.ts | 3 + src/types/matrix-sdk-events.d.ts | 1 + src/types/matrix/accountData.ts | 1 + 10 files changed, 320 insertions(+) create mode 100644 src/app/features/bookmarks/BookmarksPanel.tsx create mode 100644 src/app/hooks/useBookmarks.ts create mode 100644 src/app/pages/client/sidebar/BookmarksTab.tsx create mode 100644 src/app/state/bookmarksPanelAtom.ts diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx new file mode 100644 index 000000000..e300e6762 --- /dev/null +++ b/src/app/features/bookmarks/BookmarksPanel.tsx @@ -0,0 +1,193 @@ +import { + Avatar, + Box, + Dialog, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Text, + config, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useAtom } from 'jotai'; +import { useBookmarks, toggleBookmark } from '$hooks/useBookmarks'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { bookmarksPanelAtom } from '$state/bookmarksPanelAtom'; +import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { useGetRoom } from '$hooks/useGetRoom'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { useAllJoinedRoomsSet } from '$hooks/useGetRoom'; +import { getRoomAvatarUrl, getDirectRoomAvatarUrl } from '$utils/room'; +import { nameInitials } from '$utils/common'; +import { RoomAvatar } from '$components/room-avatar'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { stopPropagation } from '$utils/keyboard'; + +type BookmarksPanelProps = { + requestClose: () => void; +}; + +function BookmarksPanel({ requestClose }: BookmarksPanelProps) { + const mx = useMatrixClient(); + const bookmarks = useBookmarks(); + const { navigateRoom } = useRoomNavigate(); + const useAuthentication = useMediaAuthentication(); + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + + const handleOpen = (roomId: string, eventId: string) => { + navigateRoom(roomId, eventId); + requestClose(); + }; + + const handleRemove = (roomId: string, eventId: string) => { + toggleBookmark(mx, roomId, eventId, bookmarks).catch(() => {}); + }; + + return ( + +
+ + + Bookmarks + + + + +
+ + + + {bookmarks.length === 0 && ( + + + + No bookmarks yet + + + Bookmark messages from the message menu to save them here. + + + )} + {bookmarks.map((bookmark) => { + const room = getRoom(bookmark.room_id); + const event = room?.getTimelineForEvent(bookmark.event_id) + ?.getEvents() + .find((e) => e.getId() === bookmark.event_id); + + const senderDisplayName = event + ? (room?.getMember(event.getSender() ?? '')?.name ?? event.getSender() ?? 'Unknown') + : 'Unknown'; + const body = (event?.getContent() as Record | undefined)?.body as string | undefined; + const preview = body ? (body.length > 80 ? `${body.slice(0, 80)}…` : body) : 'Encrypted or unknown message'; + + return ( + handleOpen(bookmark.room_id, bookmark.event_id)} + as="button" + > + + {room ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + + + + {room?.name ?? bookmark.room_id} + + + {senderDisplayName} + + + + {preview} + + + { + e.stopPropagation(); + handleRemove(bookmark.room_id, bookmark.event_id); + }} + aria-label="Remove bookmark" + > + + + + ); + })} + + + +
+ ); +} + +export function BookmarksPanelRenderer() { + const [opened, setOpen] = useAtom(bookmarksPanelAtom); + + if (!opened) return null; + + const close = () => setOpen(false); + + return ( + }> + + + + + + + ); +} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 3224480c6..efe0e539b 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -72,6 +72,7 @@ import { MessageSourceCodeItem } from '$components/message/modals/MessageSource' import { MessageForwardItem } from '$components/message/modals/MessageForward'; import { MessageDeleteItem } from '$components/message/modals/MessageDelete'; import { MessageReportItem } from '$components/message/modals/MessageReport'; +import { useBookmarks, isBookmarked, toggleBookmark } from '$hooks/useBookmarks'; import { filterPronounsByLanguage, getParsedPronouns } from '$utils/pronouns'; import type { PronounSet } from '$utils/pronouns'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; @@ -199,6 +200,41 @@ export const MessagePinItem = as< ); }); +// Bookmark message item +export const MessageBookmarkItem = as< + 'button', + { + room: Room; + mEvent: MatrixEvent; + onClose?: () => void; + } +>(({ room, mEvent, onClose, ...props }, ref) => { + const mx = useMatrixClient(); + const bookmarks = useBookmarks(); + const eventId = mEvent.getId() ?? ''; + const bookmarked = isBookmarked(bookmarks, eventId); + + const handleToggle = () => { + toggleBookmark(mx, room.roomId, eventId, bookmarks).catch(() => {}); + onClose?.(); + }; + + return ( + } + radii="300" + onClick={handleToggle} + {...props} + ref={ref} + > + + {bookmarked ? 'Remove Bookmark' : 'Bookmark'} + + + ); +}); + export type ForwardedMessageProps = { originalTimestamp: number; isForwarded: boolean; @@ -1107,6 +1143,7 @@ function MessageInternal( )} + {canPinEvent && ( )} @@ -1445,6 +1482,7 @@ export const Event = as<'div', EventProps>( )} + {((!mEvent.isRedacted() && canDelete && !stateEvent) || (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts new file mode 100644 index 000000000..bda021e80 --- /dev/null +++ b/src/app/hooks/useBookmarks.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; +import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk'; +import { ClientEvent } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { CustomAccountDataEvent } from '$types/matrix/accountData'; + +export type BookmarkEntry = { + event_id: string; + room_id: string; +}; + +export type BookmarksContent = { + bookmarks: BookmarkEntry[]; +}; + +function readBookmarks(mx: MatrixClient): BookmarkEntry[] { + const event = mx.getAccountData(CustomAccountDataEvent.SableBookmarks); + if (!event) return []; + const content = event.getContent(); + return Array.isArray(content.bookmarks) ? content.bookmarks : []; +} + +export function useBookmarks(): BookmarkEntry[] { + const mx = useMatrixClient(); + const [bookmarks, setBookmarks] = useState(() => readBookmarks(mx)); + + useEffect(() => { + setBookmarks(readBookmarks(mx)); + const handler = (event: MatrixEvent) => { + if (event.getType() === (CustomAccountDataEvent.SableBookmarks as string)) { + const content = event.getContent(); + setBookmarks(Array.isArray(content.bookmarks) ? content.bookmarks : []); + } + }; + mx.on(ClientEvent.AccountData, handler); + return () => { + mx.off(ClientEvent.AccountData, handler); + }; + }, [mx]); + + return bookmarks; +} + +export function isBookmarked(bookmarks: BookmarkEntry[], eventId: string): boolean { + return bookmarks.some((b) => b.event_id === eventId); +} + +export async function toggleBookmark( + mx: MatrixClient, + roomId: string, + eventId: string, + currentBookmarks: BookmarkEntry[] +): Promise { + const exists = isBookmarked(currentBookmarks, eventId); + const updated: BookmarkEntry[] = exists + ? currentBookmarks.filter((b) => b.event_id !== eventId) + : [...currentBookmarks, { event_id: eventId, room_id: roomId }]; + await mx.setAccountData(CustomAccountDataEvent.SableBookmarks, { bookmarks: updated }); +} diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index aec62cc86..490113e6f 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -29,6 +29,7 @@ import { getFallbackSession, MATRIX_SESSIONS_KEY } from '$state/sessions'; import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { NotificationJumper } from '$hooks/useNotificationJumper'; import { SearchModalRenderer } from '$features/search'; +import { BookmarksPanelRenderer } from '$features/bookmarks/BookmarksPanel'; import { GlobalKeyboardShortcuts } from '$components/GlobalKeyboardShortcuts'; import { CallEmbedProvider } from '$components/CallEmbedProvider'; import { AuthLayout, Login, Register, ResetPassword } from './auth'; @@ -191,6 +192,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index 6cf397807..e8be0f616 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -16,6 +16,7 @@ import { UnverifiedTab, SearchTab, AccountSwitcherTab, + BookmarksTab, } from './sidebar'; import { CreateTab } from './sidebar/CreateTab'; @@ -133,6 +134,7 @@ export function SidebarNav() { sticky={ + diff --git a/src/app/pages/client/sidebar/BookmarksTab.tsx b/src/app/pages/client/sidebar/BookmarksTab.tsx new file mode 100644 index 000000000..089d96966 --- /dev/null +++ b/src/app/pages/client/sidebar/BookmarksTab.tsx @@ -0,0 +1,20 @@ +import { Icon, Icons } from 'folds'; +import { useAtom } from 'jotai'; +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '$components/sidebar'; +import { bookmarksPanelAtom } from '$state/bookmarksPanelAtom'; + +export function BookmarksTab() { + const [opened, setOpen] = useAtom(bookmarksPanelAtom); + + return ( + + + {(triggerRef) => ( + setOpen((o) => !o)}> + + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index 08a9099c0..27bbfef75 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -7,3 +7,4 @@ export * from './ExploreTab'; export * from './UnverifiedTab'; export * from './SearchTab'; export * from './AccountSwitcherTab'; +export * from './BookmarksTab'; diff --git a/src/app/state/bookmarksPanelAtom.ts b/src/app/state/bookmarksPanelAtom.ts new file mode 100644 index 000000000..0981751c0 --- /dev/null +++ b/src/app/state/bookmarksPanelAtom.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const bookmarksPanelAtom = atom(false); diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts index 17ca0888c..887e54fe5 100644 --- a/src/types/matrix-sdk-events.d.ts +++ b/src/types/matrix-sdk-events.d.ts @@ -50,5 +50,6 @@ declare module 'matrix-js-sdk/lib/@types/event' { [prefix.MATRIX_UNSTABLE_ACCOUNT_EMOTE_ROOMS_PROPERTY_NAME]: EmoteRoomsContent; [prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_NICKNAMES_PROPERTY_NAME]: Record; [prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_SETTINGS_PROPERTY_NAME]: Record; + 'moe.sable.app.bookmarks': { bookmarks: Array<{ event_id: string; room_id: string }> }; } } diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 670effb19..db2ed9838 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -10,6 +10,7 @@ export const CustomAccountDataEvent = { SablePerProfileMessageProfiles: prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, SableSettings: prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_SETTINGS_PROPERTY_NAME, + SableBookmarks: 'moe.sable.app.bookmarks', } as const; export type CustomAccountDataEvent = (typeof CustomAccountDataEvent)[keyof typeof CustomAccountDataEvent]; From 023929f70c3fdfab5bde055bd2df2756db0ce194 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 17:39:43 -0400 Subject: [PATCH 08/68] Change GitHub owner from 'SableClient' to 'Just-Insane' --- knope.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knope.toml b/knope.toml index fc533824c..9be54d79d 100644 --- a/knope.toml +++ b/knope.toml @@ -62,7 +62,7 @@ help_text = "Create a new change file to be included in the next release" type = "CreateChangeFile" [github] -owner = "SableClient" +owner = "Just-Insane" repo = "Sable" [release_notes] From af4403624880b42f04dd95272f698c349f249f70 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 17:40:40 -0400 Subject: [PATCH 09/68] Change default custom domain for Worker --- infra/web/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/web/variables.tf b/infra/web/variables.tf index 3569c9822..7d3a50ece 100644 --- a/infra/web/variables.tf +++ b/infra/web/variables.tf @@ -7,7 +7,7 @@ variable "account_id" { variable "custom_domain" { description = "Custom domain attached to the Worker" type = string - default = "app.sable.moe" + default = "app.cloudhub.social" } variable "worker_name" { From 5154cb99554b81b8c4c0686d657d35023c377001 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 17:45:52 -0400 Subject: [PATCH 10/68] Change default custom domain in variables.tf --- infra/web/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/web/variables.tf b/infra/web/variables.tf index 7d3a50ece..96a6be2c5 100644 --- a/infra/web/variables.tf +++ b/infra/web/variables.tf @@ -7,7 +7,7 @@ variable "account_id" { variable "custom_domain" { description = "Custom domain attached to the Worker" type = string - default = "app.cloudhub.social" + default = "sable.cloudhub.social" } variable "worker_name" { From 869ba50df8d9029ae44c3f027391daa9f5f148ed Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 18:20:43 -0400 Subject: [PATCH 11/68] Change default custom domain to dev.cloudhub.social --- infra/web/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/web/variables.tf b/infra/web/variables.tf index 96a6be2c5..8ddd72ae4 100644 --- a/infra/web/variables.tf +++ b/infra/web/variables.tf @@ -7,7 +7,7 @@ variable "account_id" { variable "custom_domain" { description = "Custom domain attached to the Worker" type = string - default = "sable.cloudhub.social" + default = "dev.cloudhub.social" } variable "worker_name" { From 3245ba91d1ce3eeaa260758ba273d07c57b35abe Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 18:30:52 -0400 Subject: [PATCH 12/68] Refactor config.json for new homeserver settings Updated configuration for homeserver and push notifications. --- config.json | 46 +++++++++++++++++----------------------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/config.json b/config.json index 2809e4f68..4bf6428ba 100644 --- a/config.json +++ b/config.json @@ -1,46 +1,34 @@ { "defaultHomeserver": 0, - "homeserverList": ["matrix.org", "mozilla.org", "unredacted.org", "sable.moe", "kendama.moe"], + "homeserverList": [ + "https://matrix.cloudhub.social" + ], "allowCustomHomeservers": true, - "elementCallUrl": null, - + "elementCallUrl": "matrix.cloudhub.social", "disableAccountSwitcher": false, "hideUsernamePasswordFields": false, - "pushNotificationDetails": { - "pushNotifyUrl": "https://sygnal.sable.moe/_matrix/push/v1/notify", - "vapidPublicKey": "BCnS4SbHjeOaqVFW4wjt5xDt_pYIL62qMzKePfYF9fl9PQU14RieIaObh7nLR_9dQf4sykZa-CTrcjkgMIE1mcg", - "webPushAppID": "moe.sable.app.sygnal" + "pushNotifyUrl": "https://sygnal.cloudhub.social/_matrix/push/v1/notify", + "vapidPublicKey": "BEBdK6VUiqYxcOauFCM1ZB38llgiODAs6pR5EEcC7YBoUh2YvrULagwo5t-Ms0Is0lEmKDhpdUoMiy_i7ArI3oE", + "webPushAppID": "social.cloudhub.sable.web" }, - "themeCatalogBaseUrl": "https://raw.githubusercontent.com/SableClient/themes/main/", - "themeCatalogApprovedHostPrefixes": ["https://raw.githubusercontent.com/SableClient/themes/"], - + "themeCatalogApprovedHostPrefixes": [ + "https://raw.githubusercontent.com/SableClient/themes/" + ], "slidingSync": { "enabled": true }, - "featuredCommunities": { "openAsDefault": false, - "spaces": [ - "#sable:sable.moe", - "#community:matrix.org", - "#space:unredacted.org", - "#science-space:matrix.org", - "#libregaming-games:tchncs.de", - "#mathematics-on:matrix.org" - ], - "rooms": [ - "#announcements:sable.moe", - "#freesoftware:matrix.org", - "#pcapdroid:matrix.org", - "#gentoo:matrix.org", - "#PrivSec.dev:arcticfoxes.net", - "#disroot:aria-net.org" - ], - "servers": ["matrixrooms.info", "mozilla.org", "unredacted.org"] + "spaces": [], + "rooms": [], + "servers": [ + "matrixrooms.info", + "gitter.im", + "matrix.org" + ] }, - "hashRouter": { "enabled": false, "basename": "/" From 2c11a630913d901f879e83c03c93dd08c19f77e3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 24 Mar 2026 20:20:08 -0400 Subject: [PATCH 13/68] chore: ignore .vscode/launch.json --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 76af75542..2c11dd8cf 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ result .direnv ## auto-generated pre-commit config .pre-commit-config.yaml +.vscode/launch.json From 2bd4a96bab455510767b7bf3498f85f28b72321f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 24 Mar 2026 22:33:41 -0400 Subject: [PATCH 14/68] ci: build latest Docker image from integration branch too --- .github/workflows/docker-publish.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 03a63ef99..426f1d826 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Build and publish Docker image on: push: - branches: [dev] + branches: [dev, integration] tags: - 'v*' pull_request: @@ -70,9 +70,9 @@ jobs: flavor: | latest=false tags: | - # dev branch or manual dispatch without a tag: short commit SHA + latest - type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + # dev/integration branch or manual dispatch without a tag: short commit SHA + latest + type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/integration' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/integration' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} # git tags (push or manual dispatch with a tag): semver breakdown type=semver,pattern={{version}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' }} From 4428c663a29384a4b42b4c0bb99825fc681e5bd6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 25 Mar 2026 00:08:01 -0400 Subject: [PATCH 15/68] ci: add Sentry env vars to Docker image build step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass VITE_SENTRY_DSN, VITE_SENTRY_ENVIRONMENT, VITE_APP_VERSION, SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT to the build step so that the Docker image build (dev, integration, and release tags) includes Sentry instrumentation and source map uploads, matching the Cloudflare deploy workflow. Environment mapping: - dev branch / release tags → production - integration branch / manual dispatch without tag → preview --- .github/workflows/docker-publish.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 426f1d826..64c78f755 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -90,6 +90,12 @@ jobs: env: VITE_BUILD_HASH: ${{ steps.vars.outputs.short_sha }} VITE_IS_RELEASE_TAG: ${{ steps.release_tag.outputs.is_release }} + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ (steps.release_tag.outputs.is_release == 'true' || github.ref == 'refs/heads/dev') && 'production' || 'preview' }} + VITE_APP_VERSION: ${{ github.ref_name }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: | NODE_OPTIONS=--max_old_space_size=4096 pnpm run build From 4fd781b010770a46a8fde16282f25f81b0e986b0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 27 Mar 2026 10:26:35 -0400 Subject: [PATCH 16/68] ci: tag integration branch Docker image as 'integration' --- .github/workflows/docker-publish.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 64c78f755..82fa8406f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -70,9 +70,14 @@ jobs: flavor: | latest=false tags: | - # dev/integration branch or manual dispatch without a tag: short commit SHA + latest + # dev/integration branch or manual dispatch without a tag: short commit SHA type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/integration' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/integration' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + + # dev branch or manual dispatch without a tag: latest tag + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + + # integration branch: stable integration tag + type=raw,value=integration,enable=${{ github.ref == 'refs/heads/integration' }} # git tags (push or manual dispatch with a tag): semver breakdown type=semver,pattern={{version}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' }} From e507254203eccd123f78682e79885baabd8e91c2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 12:14:10 -0400 Subject: [PATCH 17/68] feat: add pre-push git hook for quality checks - Adds pre-push hook that runs typecheck, lint, and format checks - Blocks pushes that would fail CI - Includes install script for easy setup - Tracked on personal/config to persist across dev pulls --- scripts/git-hooks/README.md | 28 ++++++++++++++++++++++++++++ scripts/git-hooks/pre-push | 35 +++++++++++++++++++++++++++++++++++ scripts/install-git-hooks.sh | 25 +++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 scripts/git-hooks/README.md create mode 100644 scripts/git-hooks/pre-push create mode 100644 scripts/install-git-hooks.sh diff --git a/scripts/git-hooks/README.md b/scripts/git-hooks/README.md new file mode 100644 index 000000000..2793d1921 --- /dev/null +++ b/scripts/git-hooks/README.md @@ -0,0 +1,28 @@ +# Git Hooks + +This directory contains git hooks that enforce quality standards before pushing code. + +## Installation + +Run the installation script from the repository root: + +```bash +./scripts/install-git-hooks.sh +``` + +This will copy the hooks to `.git/hooks/` and make them executable. + +## Hooks + +### pre-push + +Runs before every `git push` and enforces: +- TypeScript type checking (`npm run typecheck`) +- ESLint checks (`npm run lint`) +- Prettier formatting (`npm run fmt:check`) + +If any check fails, the push is blocked. To bypass in emergencies: `git push --no-verify` + +## Maintenance + +This directory is tracked on the `personal/config` branch to persist across `dev` pulls and merges. diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100644 index 000000000..d4c02c37a --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,35 @@ +#!/bin/zsh +# Pre-push hook: Run quality checks before allowing push +# This prevents pushing code that will fail CI checks + +set -e + +echo "🔍 Running pre-push quality checks..." + +# Run typecheck +echo " → Running typecheck..." +if ! npm run typecheck > /dev/null 2>&1; then + echo "❌ Typecheck failed. Fix errors before pushing." + npm run typecheck + exit 1 +fi +echo " ✓ Typecheck passed" + +# Run lint +echo " → Running lint..." +if ! npm run lint > /dev/null 2>&1; then + echo "❌ Lint failed. Fix errors before pushing." + npm run lint + exit 1 +fi +echo " ✓ Lint passed" + +# Run format check +echo " → Running format check..." +if ! npm run fmt:check > /dev/null 2>&1; then + echo "❌ Format check failed. Run 'npm run fmt' to fix." + exit 1 +fi +echo " ✓ Format check passed" + +echo "✅ All quality checks passed. Proceeding with push..." diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100644 index 000000000..c90efc819 --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,25 @@ +#!/bin/zsh +# Setup script: Install git hooks from scripts/git-hooks/ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +HOOKS_DIR="$REPO_ROOT/.git/hooks" +SOURCE_DIR="$REPO_ROOT/scripts/git-hooks" + +echo "🔧 Installing git hooks..." + +# Install pre-push hook +if [ -f "$SOURCE_DIR/pre-push" ]; then + cp "$SOURCE_DIR/pre-push" "$HOOKS_DIR/pre-push" + chmod +x "$HOOKS_DIR/pre-push" + echo " ✓ Installed pre-push hook" +else + echo " ⚠ pre-push hook not found in $SOURCE_DIR" +fi + +echo "✅ Git hooks installation complete!" +echo "" +echo "The pre-push hook will now run quality checks (typecheck, lint, format)" +echo "before every git push. To bypass in emergencies, use: git push --no-verify" From eac403dff4cd3ba6a4efbdada294ef04ef2d9512 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 19:56:52 -0400 Subject: [PATCH 18/68] ci(docker): load env-specific client config overrides --- .github/workflows/docker-publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 82fa8406f..5badb90a4 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -23,12 +23,16 @@ env: jobs: build-and-push: runs-on: ubuntu-latest + environment: ${{ github.event_name == 'pull_request' && (github.base_ref == 'dev' && 'production' || github.base_ref == 'integration' && 'preview' || 'preview') || github.ref == 'refs/heads/dev' && 'production' || github.ref == 'refs/heads/integration' && 'preview' || 'preview' }} permissions: contents: read packages: write attestations: write artifact-metadata: write id-token: write + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} steps: - name: Checkout repository From 42554ed007895220e0df35fc153fc123c61be87c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 20:01:23 -0400 Subject: [PATCH 19/68] ci: integration uses preview env, dev uses production env --- .github/workflows/cloudflare-dev-deploy.yml | 103 +++++++++++++++++++ .github/workflows/cloudflare-web-preview.yml | 2 +- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/cloudflare-dev-deploy.yml diff --git a/.github/workflows/cloudflare-dev-deploy.yml b/.github/workflows/cloudflare-dev-deploy.yml new file mode 100644 index 000000000..e113e954d --- /dev/null +++ b/.github/workflows/cloudflare-dev-deploy.yml @@ -0,0 +1,103 @@ +name: Cloudflare Worker Dev Deploy + +on: + push: + branches: + - dev + paths: + - 'src/**' + - 'index.html' + - 'package.json' + - 'package-lock.json' + - 'vite.config.ts' + - 'tsconfig.json' + - '.github/workflows/cloudflare-dev-deploy.yml' + - '.github/actions/setup/**' + +concurrency: + group: cloudflare-worker-dev-deploy + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + permissions: + contents: read + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Prepare preview metadata + id: metadata + shell: bash + run: | + preview_message="$(git log -1 --pretty=%s)" + preview_message="$(printf '%s' "$preview_message" | head -c 100)" + + { + echo 'preview_message<> "$GITHUB_OUTPUT" + + - name: Set Sentry build environment + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + shell: bash + run: | + echo "VITE_SENTRY_DSN=$VITE_SENTRY_DSN" >> "$GITHUB_ENV" + echo "VITE_SENTRY_ENVIRONMENT=production" >> "$GITHUB_ENV" + echo "SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN" >> "$GITHUB_ENV" + echo "SENTRY_ORG=$SENTRY_ORG" >> "$GITHUB_ENV" + echo "SENTRY_PROJECT=$SENTRY_PROJECT" >> "$GITHUB_ENV" + + - name: Setup app and build + uses: ./.github/actions/setup + with: + build: 'true' + + - name: Upload Worker preview + id: deploy + uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1 + env: + PREVIEW_MESSAGE: ${{ steps.metadata.outputs.preview_message }} + with: + apiToken: ${{ secrets.TF_CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.TF_VAR_ACCOUNT_ID }} + command: > + versions upload + -c dist/wrangler.json + --preview-alias dev + --message "$PREVIEW_MESSAGE" + + - name: Publish summary + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} + SHORT_SHA: ${{ github.sha }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const deploymentUrl = process.env.DEPLOYMENT_URL; + const shortSha = process.env.SHORT_SHA?.slice(0, 7); + const now = new Date().toUTCString().replace(':00 GMT', ' UTC'); + + const tableRow = "| ✅ Dev deployment successful! | " + deploymentUrl + " | " + shortSha + " | `dev` | " + now + " |"; + const comment = [ + `## Deploying with  Cloudflare Workers  Cloudflare Workers (dev → production config)`, + ``, + `| Status | URL | Commit | Alias | Updated (UTC) |`, + `| - | - | - | - | - |`, + tableRow, + ].join("\n"); + + await core.summary.addRaw(comment).write(); diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 8b93a4bb9..eb81532fb 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -13,7 +13,7 @@ on: - '.github/actions/setup/**' push: branches: - - dev + - integration paths: - 'src/**' - 'index.html' From f9dc313cdb954c713ed32cca845ff0e877567b25 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 20:01:41 -0400 Subject: [PATCH 20/68] ci(workflows): trigger app deploys on config.json changes --- .github/workflows/cloudflare-dev-deploy.yml | 2 ++ .github/workflows/cloudflare-web-preview.yml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/cloudflare-dev-deploy.yml b/.github/workflows/cloudflare-dev-deploy.yml index e113e954d..5bc6421e0 100644 --- a/.github/workflows/cloudflare-dev-deploy.yml +++ b/.github/workflows/cloudflare-dev-deploy.yml @@ -6,9 +6,11 @@ on: - dev paths: - 'src/**' + - 'config.json' - 'index.html' - 'package.json' - 'package-lock.json' + - 'scripts/inject-client-config.js' - 'vite.config.ts' - 'tsconfig.json' - '.github/workflows/cloudflare-dev-deploy.yml' diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index eb81532fb..d7df99897 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -4,9 +4,11 @@ on: pull_request: paths: - 'src/**' + - 'config.json' - 'index.html' - 'package.json' - 'package-lock.json' + - 'scripts/inject-client-config.js' - 'vite.config.ts' - 'tsconfig.json' - '.github/workflows/cloudflare-web-preview.yml' @@ -16,9 +18,11 @@ on: - integration paths: - 'src/**' + - 'config.json' - 'index.html' - 'package.json' - 'package-lock.json' + - 'scripts/inject-client-config.js' - 'vite.config.ts' - 'tsconfig.json' - '.github/workflows/cloudflare-web-preview.yml' From ed44d017268842084b23d4e62f42a97f4c4e1079 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 23:24:27 -0400 Subject: [PATCH 21/68] chore: codespace devcontainer config --- .devcontainer/devcontainer.json | 71 ++++++++++++++++++++++++++++++++ .devcontainer/on-create.sh | 19 +++++++++ .devcontainer/post-create.sh | 72 +++++++++++++++++++++++++++++++++ .devcontainer/post-start.sh | 39 ++++++++++++++++++ .devcontainer/setup-signing.sh | 51 +++++++++++++++++++++++ .devcontainer/update-content.sh | 19 +++++++++ sable.code-workspace | 27 +++++++++++++ 7 files changed, 298 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/on-create.sh create mode 100644 .devcontainer/post-create.sh create mode 100644 .devcontainer/post-start.sh create mode 100644 .devcontainer/setup-signing.sh create mode 100644 .devcontainer/update-content.sh create mode 100644 sable.code-workspace diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..45329c341 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,71 @@ +{ + "name": "Sable", + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm", + + // Minimum 4 cores / 8 GB RAM so Vite builds and TypeScript checks don't crawl + "hostRequirements": { + "cpus": 4, + "memory": "8gb", + "storage": "32gb" + }, + + "features": { + // GitHub CLI for PR/issue/fork management + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + + // Expose Vite dev server and Zola docs preview + "forwardPorts": [5173, 8080, 1111], + "portsAttributes": { + "5173": { "label": "Vite Dev Server", "onAutoForward": "notify" }, + "8080": { "label": "App Preview", "onAutoForward": "notify" }, + "1111": { "label": "Docs Preview (Zola)", "onAutoForward": "notify" } + }, + + // Open the multi-root workspace covering both Sable + Sable-Docs + "workspaceFile": "${localWorkspaceFolder}/sable.code-workspace", + + "customizations": { + "vscode": { + "extensions": [ + // JS/TS toolchain + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "webpro.vscode-knip", + "ms-vscode.vscode-typescript-next", + // Git & GitHub + "github.vscode-pull-request-github", + "eamodio.gitlens", + // Docs (Zola / TOML / Markdown) + "tamasfe.even-better-toml", + "yzhang.markdown-all-in-one", + "eliostruyf.vscode-front-matter", + // Misc + "EditorConfig.EditorConfig" + ], + "settings": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "typescript.tsdk": "node_modules/typescript/lib", + "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml" }, + "git.autofetch": true, + "terminal.integrated.defaultProfile.linux": "bash" + } + } + }, + + // ── Lifecycle hooks ──────────────────────────────────────────────────────── + // on-create : runs ONCE when the prebuild image is first built (cached) + // update-content: re-runs on each prebuild refresh & new codespace create (cached) + // post-create : runs once on each new codespace (not cached) – user-specific setup + // post-start : runs on EVERY codespace start (fetch upstream, signing check) + + "onCreateCommand": "bash .devcontainer/on-create.sh", + "updateContentCommand": "bash .devcontainer/update-content.sh", + "postCreateCommand": "bash .devcontainer/post-create.sh", + "postStartCommand": "bash .devcontainer/post-start.sh", + + "remoteUser": "node" +} diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh new file mode 100644 index 000000000..1d5123eaa --- /dev/null +++ b/.devcontainer/on-create.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# on-create.sh — runs ONCE when the prebuild image is first built +# Everything here is cached between prebuild refreshes. +set -euo pipefail + +echo "==> [on-create] Enabling corepack (pnpm)..." +corepack enable +corepack prepare pnpm@latest --activate + +echo "==> [on-create] Configuring pnpm global store..." +pnpm config set store-dir /home/node/.local/share/pnpm/store + +echo "==> [on-create] Installing Zola (for Sable-Docs preview)..." +ZOLA_VERSION="0.19.2" +ZOLA_URL="https://github.com/getzola/zola/releases/download/v${ZOLA_VERSION}/zola-v${ZOLA_VERSION}-x86_64-unknown-linux-gnu.tar.gz" +curl -fsSL "$ZOLA_URL" | sudo tar xz -C /usr/local/bin +zola --version + +echo "==> [on-create] Done." diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100644 index 000000000..4f2ef27a1 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# post-create.sh — runs ONCE per new codespace (not cached in prebuild). +# Handles user-specific git setup: remotes, branches, signing. +set -euo pipefail + +SABLE_DIR="/workspaces/Sable" +DOCS_DIR="/workspaces/Sable-Docs" + +# ── 1. Upstream remotes ─────────────────────────────────────────────────────── +echo "==> [post-create] Configuring upstream remotes..." + +# Sable: fork = origin (Just-Insane/Sable), upstream = SableClient/Sable +if ! git -C "$SABLE_DIR" remote | grep -q "^upstream$"; then + git -C "$SABLE_DIR" remote add upstream https://github.com/SableClient/Sable.git + echo " Added upstream → SableClient/Sable" +else + echo " upstream remote already set" +fi +git -C "$SABLE_DIR" fetch --all --quiet + +# Docs: fork = origin (Just-Insane/docs), upstream = SableClient/docs +if ! git -C "$DOCS_DIR" remote | grep -q "^upstream$"; then + git -C "$DOCS_DIR" remote add upstream https://github.com/SableClient/docs.git + echo " [docs] Added upstream → SableClient/docs" +else + echo " [docs] upstream remote already set" +fi +git -C "$DOCS_DIR" fetch --all --quiet + +# ── 2. Ensure required branches exist ──────────────────────────────────────── +echo "==> [post-create] Ensuring branches exist in Sable..." + +ensure_branch() { + local dir="$1" + local branch="$2" + local start_point="${3:-HEAD}" + if git -C "$dir" ls-remote --heads origin "$branch" | grep -q "$branch"; then + echo " Branch '$branch' already exists on origin, checking out..." + git -C "$dir" fetch origin "$branch" --quiet + if ! git -C "$dir" show-ref --quiet "refs/heads/$branch"; then + git -C "$dir" branch --track "$branch" "origin/$branch" + fi + else + echo " Creating branch '$branch' from $start_point and pushing to origin..." + git -C "$dir" checkout -b "$branch" "$start_point" 2>/dev/null || true + git -C "$dir" push -u origin "$branch" + fi +} + +# Switch back to integration after branch ops +CURRENT_BRANCH=$(git -C "$SABLE_DIR" rev-parse --abbrev-ref HEAD) + +ensure_branch "$SABLE_DIR" "integration" "upstream/dev" +ensure_branch "$SABLE_DIR" "personal/config" "integration" +ensure_branch "$DOCS_DIR" "integration" "upstream/main" + +# Return to whatever branch we were on +git -C "$SABLE_DIR" checkout "$CURRENT_BRANCH" 2>/dev/null || true + +# ── 3. Git signing (SSH via forwarded YubiKey) ──────────────────────────────── +echo "==> [post-create] Configuring SSH commit signing..." +bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true + +# ── 4. Install git hooks ────────────────────────────────────────────────────── +echo "==> [post-create] Installing git hooks..." +if [ -f "$SABLE_DIR/scripts/install-git-hooks.sh" ]; then + bash "$SABLE_DIR/scripts/install-git-hooks.sh" +fi + +echo "" +echo "==> [post-create] Done! Open sable.code-workspace for the multi-root view." +echo " Run '.devcontainer/setup-signing.sh' any time to reconfigure commit signing." diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh new file mode 100644 index 000000000..c49b2eeb2 --- /dev/null +++ b/.devcontainer/post-start.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# post-start.sh — runs on EVERY codespace start. +# Fetches upstream changes and re-checks signing (agent may have changed). +set -euo pipefail + +SABLE_DIR="/workspaces/Sable" +DOCS_DIR="/workspaces/Sable-Docs" + +# ── Fetch upstream for both repos ──────────────────────────────────────────── +echo "==> [post-start] Fetching upstream..." +git -C "$SABLE_DIR" fetch upstream --quiet 2>/dev/null && echo " Sable upstream fetched" || echo " ⚠ Could not fetch Sable upstream" +git -C "$DOCS_DIR" fetch upstream --quiet 2>/dev/null && echo " Docs upstream fetched" || echo " ⚠ Could not fetch Docs upstream" + +# ── Show how far behind integration is from upstream/dev ───────────────────── +BEHIND=$(git -C "$SABLE_DIR" rev-list --count HEAD..upstream/dev 2>/dev/null || echo "?") +if [ "$BEHIND" != "0" ] && [ "$BEHIND" != "?" ]; then + echo "" + echo " ℹ Your current branch is $BEHIND commit(s) behind upstream/dev." + echo " To sync: git merge upstream/dev (or: git rebase upstream/dev)" +fi + +# ── Re-configure SSH signing if not already set (agent may now be available) ─ +if [ "$(git config --global gpg.format 2>/dev/null)" != "ssh" ]; then + bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true +else + # Verify the key still exists in the agent (yubikey could have changed) + CONFIGURED_KEY=$(git config --global user.signingkey 2>/dev/null || echo "") + if [ -n "$CONFIGURED_KEY" ]; then + if ! ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then + echo "" + echo " ⚠ Signing key not found in SSH agent. YubiKey present?" + echo " Re-run: bash .devcontainer/setup-signing.sh" + else + echo " ✓ Commit signing ready (SSH via forwarded agent)" + fi + fi +fi + +echo "" diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh new file mode 100644 index 000000000..d8262cf3b --- /dev/null +++ b/.devcontainer/setup-signing.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# setup-signing.sh — configures SSH commit signing via forwarded SSH agent. +# Safe to re-run at any time. YubiKey-backed keys work as long as the +# SSH agent from your local machine is forwarded (VS Code handles this). +set -euo pipefail + +SABLE_DIR="/workspaces/Sable" +ALLOWED_SIGNERS_FILE="$HOME/.config/git/allowed_signers" + +# Check if SSH agent is available and has keys loaded +if ! ssh-add -L &>/dev/null || [ -z "$(ssh-add -L 2>/dev/null)" ]; then + echo "⚠ No SSH keys found in the forwarded agent." + echo " Make sure your local SSH agent is running and your YubiKey key is loaded." + echo " On macOS: ssh-add --apple-use-keychain ~/.ssh/id_ed25519" + echo " To retry: bash .devcontainer/setup-signing.sh" + exit 0 +fi + +# Pick the first key; if your YubiKey-backed key is not first, adjust: +# e.g. SIGNING_KEY=$(ssh-add -L | grep "cardno:" | head -1) +SIGNING_KEY=$(ssh-add -L | head -1) +KEY_COMMENT=$(echo "$SIGNING_KEY" | awk '{print $NF}') + +echo "✓ Found SSH key: ...${KEY_COMMENT}" + +# Configure git to use SSH signing +git config --global gpg.format ssh +git config --global user.signingkey "$SIGNING_KEY" +git config --global commit.gpgsign true +git config --global tag.gpgsign true + +# Set up allowed_signers for local verification +USER_EMAIL=$(git config --global user.email 2>/dev/null || echo "") +if [ -n "$USER_EMAIL" ]; then + mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")" + # Remove stale entry for this email if present, then add fresh one + if [ -f "$ALLOWED_SIGNERS_FILE" ]; then + grep -v "^$USER_EMAIL " "$ALLOWED_SIGNERS_FILE" > "${ALLOWED_SIGNERS_FILE}.tmp" || true + mv "${ALLOWED_SIGNERS_FILE}.tmp" "$ALLOWED_SIGNERS_FILE" + fi + echo "$USER_EMAIL namespaces=\"git\" $SIGNING_KEY" >> "$ALLOWED_SIGNERS_FILE" + git config --global gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS_FILE" + echo "✓ SSH commit signing configured for <$USER_EMAIL>" +else + echo "⚠ user.email not set globally. Run: git config --global user.email 'you@example.com'" + echo " Then re-run: bash .devcontainer/setup-signing.sh" +fi + +echo "" +echo "Test signing with: git commit --allow-empty -m 'test signing'" +echo "Verify with: git log --show-signature -1" diff --git a/.devcontainer/update-content.sh b/.devcontainer/update-content.sh new file mode 100644 index 000000000..572ae73ba --- /dev/null +++ b/.devcontainer/update-content.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# update-content.sh — runs on each prebuild refresh AND on new codespace creation. +# The resulting filesystem state is cached in the prebuild snapshot. +set -euo pipefail + +echo "==> [update-content] Installing Sable dependencies (pnpm install)..." +pnpm install --frozen-lockfile + +echo "==> [update-content] Cloning / updating Sable-Docs..." +DOCS_DIR="/workspaces/Sable-Docs" +if [ -d "$DOCS_DIR/.git" ]; then + echo " Docs already present, fetching latest..." + git -C "$DOCS_DIR" fetch --all +else + echo " Cloning Just-Insane/docs → $DOCS_DIR" + git clone https://github.com/Just-Insane/docs "$DOCS_DIR" +fi + +echo "==> [update-content] Done." diff --git a/sable.code-workspace b/sable.code-workspace new file mode 100644 index 000000000..b7b699ce8 --- /dev/null +++ b/sable.code-workspace @@ -0,0 +1,27 @@ +{ + "folders": [ + { + "path": ".", + "name": "Sable" + }, + { + "path": "../Sable-Docs", + "name": "Sable-Docs" + } + ], + "settings": { + "editor.formatOnSave": true, + "typescript.tsdk": "Sable/node_modules/typescript/lib" + }, + "extensions": { + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "webpro.vscode-knip", + "tamasfe.even-better-toml", + "yzhang.markdown-all-in-one", + "github.vscode-pull-request-github", + "eamodio.gitlens" + ] + } +} From 18712ac6e9f461a3392f449fbc97e73842bbc03f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 23:29:30 -0400 Subject: [PATCH 22/68] Update image --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 45329c341..ddf43676c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Sable", - "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm", + "image": "mcr.microsoft.com/devcontainers/javascript-node:24-bookworm", // Minimum 4 cores / 8 GB RAM so Vite builds and TypeScript checks don't crawl "hostRequirements": { From b01a0902328834a20d2c9057913114c140ab5ac9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 23:35:08 -0400 Subject: [PATCH 23/68] update startup script --- .devcontainer/on-create.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh index 1d5123eaa..7f6f789d8 100644 --- a/.devcontainer/on-create.sh +++ b/.devcontainer/on-create.sh @@ -4,7 +4,7 @@ set -euo pipefail echo "==> [on-create] Enabling corepack (pnpm)..." -corepack enable +sudo corepack enable corepack prepare pnpm@latest --activate echo "==> [on-create] Configuring pnpm global store..." From b35f1ad8d52898c01d0277c22b12e83410fc4d6c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 23:56:22 -0400 Subject: [PATCH 24/68] Update setup-signing script --- .devcontainer/setup-signing.sh | 61 ++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh index d8262cf3b..9191572f9 100644 --- a/.devcontainer/setup-signing.sh +++ b/.devcontainer/setup-signing.sh @@ -1,29 +1,49 @@ #!/usr/bin/env bash -# setup-signing.sh — configures SSH commit signing via forwarded SSH agent. -# Safe to re-run at any time. YubiKey-backed keys work as long as the -# SSH agent from your local machine is forwarded (VS Code handles this). +# setup-signing.sh — configures SSH commit signing. +# Supports two modes: +# 1. Forwarded SSH agent (VS Code desktop + YubiKey) +# 2. Codespace-local SSH key (browser/web Codespaces) +# Safe to re-run at any time. set -euo pipefail SABLE_DIR="/workspaces/Sable" ALLOWED_SIGNERS_FILE="$HOME/.config/git/allowed_signers" +CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" -# Check if SSH agent is available and has keys loaded -if ! ssh-add -L &>/dev/null || [ -z "$(ssh-add -L 2>/dev/null)" ]; then - echo "⚠ No SSH keys found in the forwarded agent." - echo " Make sure your local SSH agent is running and your YubiKey key is loaded." - echo " On macOS: ssh-add --apple-use-keychain ~/.ssh/id_ed25519" - echo " To retry: bash .devcontainer/setup-signing.sh" - exit 0 -fi - -# Pick the first key; if your YubiKey-backed key is not first, adjust: -# e.g. SIGNING_KEY=$(ssh-add -L | grep "cardno:" | head -1) -SIGNING_KEY=$(ssh-add -L | head -1) -KEY_COMMENT=$(echo "$SIGNING_KEY" | awk '{print $NF}') +# ── MODE 1: Forwarded SSH agent (desktop VS Code) ──────────────────────────── +if ssh-add -L &>/dev/null && [ -n "$(ssh-add -L 2>/dev/null)" ]; then + echo "✓ Detected forwarded SSH agent (desktop VS Code + YubiKey mode)" + SIGNING_KEY=$(ssh-add -L | head -1) + KEY_COMMENT=$(echo "$SIGNING_KEY" | awk '{print $NF}') + echo " Using key: ...${KEY_COMMENT}" -echo "✓ Found SSH key: ...${KEY_COMMENT}" +# ── MODE 2: Codespace-local key (web Codespaces) ───────────────────────────── +else + echo "ℹ No forwarded agent (web Codespace mode)" + + if [ ! -f "$CODESPACE_KEY" ]; then + echo " Generating new Ed25519 signing key..." + mkdir -p "$HOME/.ssh" + ssh-keygen -t ed25519 -f "$CODESPACE_KEY" -N "" -C "codespace-signing@$(hostname)" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " 🔑 Add this PUBLIC KEY to GitHub as a SIGNING key:" + echo "" + cat "${CODESPACE_KEY}.pub" + echo "" + echo " 👉 https://github.com/settings/keys → New SSH key" + echo " Title: Codespace Signing Key" + echo " Key type: Signing Key" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + read -p "Press Enter after adding the key to GitHub..." + fi + + SIGNING_KEY=$(cat "${CODESPACE_KEY}.pub") + echo " Using Codespace key: ${CODESPACE_KEY}" +fi -# Configure git to use SSH signing +# ── Common: Configure git ──────────────────────────────────────────────────── git config --global gpg.format ssh git config --global user.signingkey "$SIGNING_KEY" git config --global commit.gpgsign true @@ -33,7 +53,6 @@ git config --global tag.gpgsign true USER_EMAIL=$(git config --global user.email 2>/dev/null || echo "") if [ -n "$USER_EMAIL" ]; then mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")" - # Remove stale entry for this email if present, then add fresh one if [ -f "$ALLOWED_SIGNERS_FILE" ]; then grep -v "^$USER_EMAIL " "$ALLOWED_SIGNERS_FILE" > "${ALLOWED_SIGNERS_FILE}.tmp" || true mv "${ALLOWED_SIGNERS_FILE}.tmp" "$ALLOWED_SIGNERS_FILE" @@ -47,5 +66,5 @@ else fi echo "" -echo "Test signing with: git commit --allow-empty -m 'test signing'" -echo "Verify with: git log --show-signature -1" +echo "Test signing: git commit --allow-empty -m 'test signing'" +echo "Verify: git log --show-signature -1" \ No newline at end of file From 8e1f0a60a95857b35f9254df9afc3144a7068545 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 00:06:13 -0400 Subject: [PATCH 25/68] Updates for ssh --- .devcontainer/post-start.sh | 20 +++++++++++++++----- .devcontainer/setup-signing.sh | 10 ++++++++++ sable.code-workspace | 14 +++++++------- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index c49b2eeb2..7afe0c3cc 100644 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -20,18 +20,28 @@ if [ "$BEHIND" != "0" ] && [ "$BEHIND" != "?" ]; then fi # ── Re-configure SSH signing if not already set (agent may now be available) ─ +CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" if [ "$(git config --global gpg.format 2>/dev/null)" != "ssh" ]; then bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true else - # Verify the key still exists in the agent (yubikey could have changed) + # Verify the key still exists in the agent CONFIGURED_KEY=$(git config --global user.signingkey 2>/dev/null || echo "") if [ -n "$CONFIGURED_KEY" ]; then if ! ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then - echo "" - echo " ⚠ Signing key not found in SSH agent. YubiKey present?" - echo " Re-run: bash .devcontainer/setup-signing.sh" + # In web Codespace mode, reload the key into a fresh agent + if [ -f "$CODESPACE_KEY" ]; then + echo " ↻ Reloading Codespace signing key into SSH agent..." + if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l &>/dev/null; then + eval "$(ssh-agent -s)" > /dev/null + echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" + echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" + fi + ssh-add "$CODESPACE_KEY" 2>/dev/null && echo " ✓ Commit signing ready" + else + echo " ⚠ Signing key not found. YubiKey present or re-run: bash .devcontainer/setup-signing.sh" + fi else - echo " ✓ Commit signing ready (SSH via forwarded agent)" + echo " ✓ Commit signing ready" fi fi fi diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh index 9191572f9..189f29383 100644 --- a/.devcontainer/setup-signing.sh +++ b/.devcontainer/setup-signing.sh @@ -39,6 +39,16 @@ else read -p "Press Enter after adding the key to GitHub..." fi + # Start ssh-agent if not already running and add the key + if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l &>/dev/null; then + echo " Starting SSH agent and loading key..." + eval "$(ssh-agent -s)" > /dev/null + ssh-add "$CODESPACE_KEY" 2>/dev/null + # Persist SSH_AUTH_SOCK for future shells + echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" + echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" + fi + SIGNING_KEY=$(cat "${CODESPACE_KEY}.pub") echo " Using Codespace key: ${CODESPACE_KEY}" fi diff --git a/sable.code-workspace b/sable.code-workspace index b7b699ce8..f937d83ca 100644 --- a/sable.code-workspace +++ b/sable.code-workspace @@ -2,16 +2,16 @@ "folders": [ { "path": ".", - "name": "Sable" + "name": "Sable", }, { "path": "../Sable-Docs", - "name": "Sable-Docs" - } + "name": "Sable-Docs", + }, ], "settings": { "editor.formatOnSave": true, - "typescript.tsdk": "Sable/node_modules/typescript/lib" + "typescript.tsdk": "Sable/node_modules/typescript/lib", }, "extensions": { "recommendations": [ @@ -21,7 +21,7 @@ "tamasfe.even-better-toml", "yzhang.markdown-all-in-one", "github.vscode-pull-request-github", - "eamodio.gitlens" - ] - } + "eamodio.gitlens", + ], + }, } From 9c544660752fb594c459dd525860925c57b60c56 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 00:13:24 -0400 Subject: [PATCH 26/68] More script fixes --- .devcontainer/post-start.sh | 2 +- .devcontainer/setup-signing.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index 7afe0c3cc..e4f64eda4 100644 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -31,7 +31,7 @@ else # In web Codespace mode, reload the key into a fresh agent if [ -f "$CODESPACE_KEY" ]; then echo " ↻ Reloading Codespace signing key into SSH agent..." - if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l &>/dev/null; then + if [ -z "${SSH_AUTH_SOCK:-}" ] || ! ssh-add -l &>/dev/null; then eval "$(ssh-agent -s)" > /dev/null echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh index 189f29383..647c5926c 100644 --- a/.devcontainer/setup-signing.sh +++ b/.devcontainer/setup-signing.sh @@ -40,7 +40,7 @@ else fi # Start ssh-agent if not already running and add the key - if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l &>/dev/null; then + if [ -z "${SSH_AUTH_SOCK:-}" ] || ! ssh-add -l &>/dev/null; then echo " Starting SSH agent and loading key..." eval "$(ssh-agent -s)" > /dev/null ssh-add "$CODESPACE_KEY" 2>/dev/null From a8a3ac6e1d25bf2a76c610b3a05d0b17f9dc66a6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 00:20:15 -0400 Subject: [PATCH 27/68] more fixes --- .devcontainer/post-start.sh | 27 ++++++++++++++------------- .devcontainer/setup-signing.sh | 25 +++++++++++++------------ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index e4f64eda4..f2353f39f 100644 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -24,24 +24,25 @@ CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" if [ "$(git config --global gpg.format 2>/dev/null)" != "ssh" ]; then bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true else - # Verify the key still exists in the agent + # Verify the signing key is still accessible CONFIGURED_KEY=$(git config --global user.signingkey 2>/dev/null || echo "") if [ -n "$CONFIGURED_KEY" ]; then - if ! ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then - # In web Codespace mode, reload the key into a fresh agent - if [ -f "$CODESPACE_KEY" ]; then - echo " ↻ Reloading Codespace signing key into SSH agent..." - if [ -z "${SSH_AUTH_SOCK:-}" ] || ! ssh-add -l &>/dev/null; then - eval "$(ssh-agent -s)" > /dev/null - echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" - echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" - fi - ssh-add "$CODESPACE_KEY" 2>/dev/null && echo " ✓ Commit signing ready" + # If it's a file path (MODE 2), check file exists + if [[ "$CONFIGURED_KEY" == /* ]]; then + if [ -f "$CONFIGURED_KEY" ]; then + echo " ✓ Commit signing ready (private key file)" else - echo " ⚠ Signing key not found. YubiKey present or re-run: bash .devcontainer/setup-signing.sh" + echo " ⚠ Signing key file not found: $CONFIGURED_KEY" + echo " Re-run: bash .devcontainer/setup-signing.sh" fi + # If it's a public key string (MODE 1), check agent else - echo " ✓ Commit signing ready" + if ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then + echo " ✓ Commit signing ready (forwarded agent)" + else + echo " ⚠ Signing key not in SSH agent. YubiKey present?" + echo " Re-run: bash .devcontainer/setup-signing.sh" + fi fi fi fi diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh index 647c5926c..ca5866095 100644 --- a/.devcontainer/setup-signing.sh +++ b/.devcontainer/setup-signing.sh @@ -39,17 +39,8 @@ else read -p "Press Enter after adding the key to GitHub..." fi - # Start ssh-agent if not already running and add the key - if [ -z "${SSH_AUTH_SOCK:-}" ] || ! ssh-add -l &>/dev/null; then - echo " Starting SSH agent and loading key..." - eval "$(ssh-agent -s)" > /dev/null - ssh-add "$CODESPACE_KEY" 2>/dev/null - # Persist SSH_AUTH_SOCK for future shells - echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" - echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" - fi - - SIGNING_KEY=$(cat "${CODESPACE_KEY}.pub") + # Use the private key file directly (git supports this without ssh-agent) + SIGNING_KEY="$CODESPACE_KEY" echo " Using Codespace key: ${CODESPACE_KEY}" fi @@ -67,7 +58,17 @@ if [ -n "$USER_EMAIL" ]; then grep -v "^$USER_EMAIL " "$ALLOWED_SIGNERS_FILE" > "${ALLOWED_SIGNERS_FILE}.tmp" || true mv "${ALLOWED_SIGNERS_FILE}.tmp" "$ALLOWED_SIGNERS_FILE" fi - echo "$USER_EMAIL namespaces=\"git\" $SIGNING_KEY" >> "$ALLOWED_SIGNERS_FILE" + + # For allowed_signers, always use the public key (even if signing with private key file) + if [ -f "$CODESPACE_KEY" ]; then + # MODE 2: read public key from file + PUBLIC_KEY=$(cat "${CODESPACE_KEY}.pub") + else + # MODE 1: already have public key in $SIGNING_KEY + PUBLIC_KEY="$SIGNING_KEY" + fi + + echo "$USER_EMAIL namespaces=\"git\" $PUBLIC_KEY" >> "$ALLOWED_SIGNERS_FILE" git config --global gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS_FILE" echo "✓ SSH commit signing configured for <$USER_EMAIL>" else From 5acdc452ac3d9ee2c1437587ebd74d76d8de343c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 04:31:10 +0000 Subject: [PATCH 28/68] updates --- .devcontainer/on-create.sh | 0 .devcontainer/post-create.sh | 0 .devcontainer/post-start.sh | 0 .devcontainer/setup-signing.sh | 0 .devcontainer/update-content.sh | 0 5 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .devcontainer/on-create.sh mode change 100644 => 100755 .devcontainer/post-create.sh mode change 100644 => 100755 .devcontainer/post-start.sh mode change 100644 => 100755 .devcontainer/setup-signing.sh mode change 100644 => 100755 .devcontainer/update-content.sh diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh old mode 100644 new mode 100755 diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh old mode 100644 new mode 100755 diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh old mode 100644 new mode 100755 diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh old mode 100644 new mode 100755 diff --git a/.devcontainer/update-content.sh b/.devcontainer/update-content.sh old mode 100644 new mode 100755 From 333a11bb99d1d0e9396376db8330d845227bbb66 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 04:36:12 +0000 Subject: [PATCH 29/68] add/setup extensions --- .devcontainer/devcontainer.json | 21 ++++++++++++++++++++- .vscode/extensions.json | 27 ++++++++++++++++++++++++++- .vscode/settings.json | 29 +++++++++++++++++++++++++++-- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ddf43676c..c1ffa7c9e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -33,15 +33,34 @@ "esbenp.prettier-vscode", "webpro.vscode-knip", "ms-vscode.vscode-typescript-next", + "usernamehw.errorlens", + "christian-kohler.path-intellisense", + "styled-components.vscode-styled-components", + "bradlc.vscode-tailwindcss", + // React/TypeScript + "dsznajder.es7-react-js-snippets", + "formulahendry.auto-rename-tag", + "wix.vscode-import-cost", + // i18n + "lokalise.i18n-ally", + // Testing + "vitest.explorer", // Git & GitHub "github.vscode-pull-request-github", "eamodio.gitlens", + // Infrastructure + "hashicorp.terraform", + "zamerick.vscode-caddyfile-syntax", // Docs (Zola / TOML / Markdown) "tamasfe.even-better-toml", "yzhang.markdown-all-in-one", "eliostruyf.vscode-front-matter", + "streetsidesoftware.code-spell-checker", + "davidanson.vscode-markdownlint", // Misc - "EditorConfig.EditorConfig" + "EditorConfig.EditorConfig", + "gruntfuggly.todo-tree", + "wayou.vscode-todo-highlight" ], "settings": { "editor.formatOnSave": true, diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 434432fea..c58bff3e3 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,28 @@ { - "recommendations": ["webpro.vscode-knip", "oxc.oxc-vscode"] + "recommendations": [ + // JS/TS toolchain + "webpro.vscode-knip", + "oxc.oxc-vscode", + "usernamehw.errorlens", + "christian-kohler.path-intellisense", + "styled-components.vscode-styled-components", + "bradlc.vscode-tailwindcss", + // React/TypeScript + "dsznajder.es7-react-js-snippets", + "formulahendry.auto-rename-tag", + "wix.vscode-import-cost", + // i18n + "lokalise.i18n-ally", + // Testing + "vitest.explorer", + // Infrastructure + "hashicorp.terraform", + "zamerick.vscode-caddyfile-syntax", + // Documentation + "streetsidesoftware.code-spell-checker", + "davidanson.vscode-markdownlint", + // Quality of Life + "gruntfuggly.todo-tree", + "wayou.vscode-todo-highlight" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 60018f71d..ae78bc53a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,30 @@ "editor.defaultFormatter": "oxc.oxc-vscode" }, "nixEnvSelector.nixFile": "${workspaceFolder}/flake.nix", - "nixEnvSelector.useFlakes": true -} + "nixEnvSelector.useFlakes": true, + "i18n-ally.localesPaths": [ + "public/locales" + ], + "i18n-ally.keystyle": "nested", + "i18n-ally.enabledFrameworks": [ + "react", + "i18next" + ], + "i18n-ally.namespace": true, + "i18n-ally.pathMatcher": "{locale}.json", + "errorLens.enabled": true, + "importCost.bundleSizeDecoration": "both", + "importCost.showCalculatingDecoration": true, + "todo-tree.general.tags": [ + "TODO", + "FIXME", + "HACK", + "XXX", + "NOTE", + "BUG" + ], + "todo-tree.highlights.defaultHighlight": { + "icon": "alert", + "type": "text" + } +} \ No newline at end of file From 7b1bcb996d2b19d456e08fe827ebaa56fd097759 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 10:18:03 -0400 Subject: [PATCH 30/68] chore(config): enable phase1 and phase2 session sync flags --- config.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/config.json b/config.json index 4bf6428ba..0dc2c8b80 100644 --- a/config.json +++ b/config.json @@ -32,5 +32,10 @@ "hashRouter": { "enabled": false, "basename": "/" + }, + "sessionSync": { + "phase1ForegroundResync": true, + "phase2VisibleHeartbeat": true, + "phase3AdaptiveBackoffJitter": false } -} +} \ No newline at end of file From 814b51062fc4d3585879c232a61c150108359bf3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 11:53:49 -0400 Subject: [PATCH 31/68] chore(config): add Copilot workspace instructions --- .github/copilot-instructions.md | 82 +++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..882847e80 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,82 @@ +# Sable – GitHub Copilot Workspace Instructions + +These rules apply to every chat and agent session in this workspace. + +--- + +## Git & Branching + +- **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). +- Before building `integration`, always **force-update `dev` from `upstream/dev`**: + ``` + git fetch upstream && git checkout dev && git reset --hard upstream/dev + ``` +- When asked to build `integration`, **always prompt for which feature/fix branches to include**. If a needed branch doesn't exist yet, create it first. +- Use short, scoped commit messages: `type(scope): description` (e.g. `fix(timeline): correct scroll anchor on bulk load`). + +## Quality Gates (must pass before every commit) + +Run these in order and fix all failures before committing: + +``` +pnpm lint # ESLint +pnpm fmt:check # Prettier +pnpm typecheck # TypeScript +pnpm test:run # Vitest unit tests +pnpm knip # Dead-code / unused exports check +``` + +Also run a **production build** and confirm it succeeds with no errors: +``` +pnpm build +``` + +## Pull Requests + +- Use the upstream PR template ([`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. +- Descriptions should be short, clear, and human-readable. No AI-generated explanations in the AI disclosure section. +- Each PR gets **one changeset line** (or one `fix:` + one `feat:` if both are genuinely present, though prefer separate PRs). +- PRs must not target `dev` directly without a reviewed branch. +- Before opening a PR, **search for related open and merged PRs on both `upstream` (SableClient/Sable or cinnyapp/cinny) and `origin`**. Review them to understand what else may be in flight that could affect the change. Summarise any findings and ask the user how to proceed if there is overlap or conflict. +- Before opening a PR, **search for related open issues on both `upstream` and `origin`**. If any are related, prompt the user to confirm, then link them in the PR description (`Closes #N` / `Related to #N`). +- If the PR has a corresponding `SableClient/docs` PR, link both PRs to each other in their descriptions. + +## Matrix Spec Compliance + +- New features and fixes must match the **current Matrix spec** or the relevant **MSC** if the spec change is pending. +- Check how **Element Web**, **FluffyChat**, or **Nheko** implement the same thing before diverging from established client patterns. +- Link the relevant spec section or MSC in the PR description when the change is spec-driven. + +## Feature Flags + +- Every user-visible new feature must be gated behind a **feature flag** in `config.json` / `useClientConfig`. +- Flags default to `false` (opt-in) unless the feature is a bug fix or a non-breaking improvement with no regressions. +- Document the flag in `docs/sample.env` and in the Sable-Docs documentation repo. + +## Code Quality + +- Code must follow **TypeScript/React best practices**: functional components, hooks, no class components, proper dependency arrays on `useEffect`/`useCallback`/`useMemo`. +- No `any` casts without a comment explaining why it's unavoidable. +- Comments must be **short and purposeful** — explain *why*, not *what*. No decorative separator lines (`//------`), no block comments restating the code. +- Do not add docstrings, comments, or type annotations to code that wasn't changed in the current task. +- Prefer explicit types over inferred types for public function signatures. + +## Documentation + +- When a new feature is added (or an existing one materially changed), **update the Sable-Docs repo** (`/Users/evie/git/Sable-Docs`). Add or update the relevant page under `content/features/` or `content/general/`. +- Keep docs concise — match the style of existing pages. + +## Security + +- Follow OWASP Top 10 guidance. No `innerHTML`, no `eval`, sanitise all user/Matrix-sourced content before rendering. +- Do not log or expose access tokens, room keys, or other secrets. +- Content Security Policy headers (Caddyfile / Dockerfile) must not be weakened without a documented reason. + +## Additional Rules + +- **No over-engineering**: only make changes directly requested or clearly necessary. Don't add abstractions for one-off operations. +- **Reversible actions only**: ask before deleting files/branches, force-pushing, or dropping data. +- **Dependency changes** (adding/removing packages) require explicit confirmation before running `pnpm install`. +- When resolving merge conflicts, prefer the version from the feature branch; ask if the intent is ambiguous. +- Test files live alongside source in `src/` (e.g. `*.test.ts`). Match the naming convention of existing tests. +- **Write tests when needed**: any new utility function, hook, or non-trivial logic should have a corresponding Vitest test. Bug fixes should include a regression test where feasible. From ef39d6610aaa727dd8fd83d58652eab55876cee7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 12:29:40 -0400 Subject: [PATCH 32/68] chore(config): remove devcontainer (setup didn't work out) --- .devcontainer/devcontainer.json | 90 --------------------------------- .devcontainer/on-create.sh | 19 ------- .devcontainer/post-create.sh | 72 -------------------------- .devcontainer/post-start.sh | 50 ------------------ .devcontainer/setup-signing.sh | 81 ----------------------------- .devcontainer/update-content.sh | 19 ------- 6 files changed, 331 deletions(-) delete mode 100644 .devcontainer/devcontainer.json delete mode 100755 .devcontainer/on-create.sh delete mode 100755 .devcontainer/post-create.sh delete mode 100755 .devcontainer/post-start.sh delete mode 100755 .devcontainer/setup-signing.sh delete mode 100755 .devcontainer/update-content.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index c1ffa7c9e..000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "name": "Sable", - "image": "mcr.microsoft.com/devcontainers/javascript-node:24-bookworm", - - // Minimum 4 cores / 8 GB RAM so Vite builds and TypeScript checks don't crawl - "hostRequirements": { - "cpus": 4, - "memory": "8gb", - "storage": "32gb" - }, - - "features": { - // GitHub CLI for PR/issue/fork management - "ghcr.io/devcontainers/features/github-cli:1": {} - }, - - // Expose Vite dev server and Zola docs preview - "forwardPorts": [5173, 8080, 1111], - "portsAttributes": { - "5173": { "label": "Vite Dev Server", "onAutoForward": "notify" }, - "8080": { "label": "App Preview", "onAutoForward": "notify" }, - "1111": { "label": "Docs Preview (Zola)", "onAutoForward": "notify" } - }, - - // Open the multi-root workspace covering both Sable + Sable-Docs - "workspaceFile": "${localWorkspaceFolder}/sable.code-workspace", - - "customizations": { - "vscode": { - "extensions": [ - // JS/TS toolchain - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", - "webpro.vscode-knip", - "ms-vscode.vscode-typescript-next", - "usernamehw.errorlens", - "christian-kohler.path-intellisense", - "styled-components.vscode-styled-components", - "bradlc.vscode-tailwindcss", - // React/TypeScript - "dsznajder.es7-react-js-snippets", - "formulahendry.auto-rename-tag", - "wix.vscode-import-cost", - // i18n - "lokalise.i18n-ally", - // Testing - "vitest.explorer", - // Git & GitHub - "github.vscode-pull-request-github", - "eamodio.gitlens", - // Infrastructure - "hashicorp.terraform", - "zamerick.vscode-caddyfile-syntax", - // Docs (Zola / TOML / Markdown) - "tamasfe.even-better-toml", - "yzhang.markdown-all-in-one", - "eliostruyf.vscode-front-matter", - "streetsidesoftware.code-spell-checker", - "davidanson.vscode-markdownlint", - // Misc - "EditorConfig.EditorConfig", - "gruntfuggly.todo-tree", - "wayou.vscode-todo-highlight" - ], - "settings": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "typescript.tsdk": "node_modules/typescript/lib", - "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml" }, - "git.autofetch": true, - "terminal.integrated.defaultProfile.linux": "bash" - } - } - }, - - // ── Lifecycle hooks ──────────────────────────────────────────────────────── - // on-create : runs ONCE when the prebuild image is first built (cached) - // update-content: re-runs on each prebuild refresh & new codespace create (cached) - // post-create : runs once on each new codespace (not cached) – user-specific setup - // post-start : runs on EVERY codespace start (fetch upstream, signing check) - - "onCreateCommand": "bash .devcontainer/on-create.sh", - "updateContentCommand": "bash .devcontainer/update-content.sh", - "postCreateCommand": "bash .devcontainer/post-create.sh", - "postStartCommand": "bash .devcontainer/post-start.sh", - - "remoteUser": "node" -} diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh deleted file mode 100755 index 7f6f789d8..000000000 --- a/.devcontainer/on-create.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# on-create.sh — runs ONCE when the prebuild image is first built -# Everything here is cached between prebuild refreshes. -set -euo pipefail - -echo "==> [on-create] Enabling corepack (pnpm)..." -sudo corepack enable -corepack prepare pnpm@latest --activate - -echo "==> [on-create] Configuring pnpm global store..." -pnpm config set store-dir /home/node/.local/share/pnpm/store - -echo "==> [on-create] Installing Zola (for Sable-Docs preview)..." -ZOLA_VERSION="0.19.2" -ZOLA_URL="https://github.com/getzola/zola/releases/download/v${ZOLA_VERSION}/zola-v${ZOLA_VERSION}-x86_64-unknown-linux-gnu.tar.gz" -curl -fsSL "$ZOLA_URL" | sudo tar xz -C /usr/local/bin -zola --version - -echo "==> [on-create] Done." diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh deleted file mode 100755 index 4f2ef27a1..000000000 --- a/.devcontainer/post-create.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# post-create.sh — runs ONCE per new codespace (not cached in prebuild). -# Handles user-specific git setup: remotes, branches, signing. -set -euo pipefail - -SABLE_DIR="/workspaces/Sable" -DOCS_DIR="/workspaces/Sable-Docs" - -# ── 1. Upstream remotes ─────────────────────────────────────────────────────── -echo "==> [post-create] Configuring upstream remotes..." - -# Sable: fork = origin (Just-Insane/Sable), upstream = SableClient/Sable -if ! git -C "$SABLE_DIR" remote | grep -q "^upstream$"; then - git -C "$SABLE_DIR" remote add upstream https://github.com/SableClient/Sable.git - echo " Added upstream → SableClient/Sable" -else - echo " upstream remote already set" -fi -git -C "$SABLE_DIR" fetch --all --quiet - -# Docs: fork = origin (Just-Insane/docs), upstream = SableClient/docs -if ! git -C "$DOCS_DIR" remote | grep -q "^upstream$"; then - git -C "$DOCS_DIR" remote add upstream https://github.com/SableClient/docs.git - echo " [docs] Added upstream → SableClient/docs" -else - echo " [docs] upstream remote already set" -fi -git -C "$DOCS_DIR" fetch --all --quiet - -# ── 2. Ensure required branches exist ──────────────────────────────────────── -echo "==> [post-create] Ensuring branches exist in Sable..." - -ensure_branch() { - local dir="$1" - local branch="$2" - local start_point="${3:-HEAD}" - if git -C "$dir" ls-remote --heads origin "$branch" | grep -q "$branch"; then - echo " Branch '$branch' already exists on origin, checking out..." - git -C "$dir" fetch origin "$branch" --quiet - if ! git -C "$dir" show-ref --quiet "refs/heads/$branch"; then - git -C "$dir" branch --track "$branch" "origin/$branch" - fi - else - echo " Creating branch '$branch' from $start_point and pushing to origin..." - git -C "$dir" checkout -b "$branch" "$start_point" 2>/dev/null || true - git -C "$dir" push -u origin "$branch" - fi -} - -# Switch back to integration after branch ops -CURRENT_BRANCH=$(git -C "$SABLE_DIR" rev-parse --abbrev-ref HEAD) - -ensure_branch "$SABLE_DIR" "integration" "upstream/dev" -ensure_branch "$SABLE_DIR" "personal/config" "integration" -ensure_branch "$DOCS_DIR" "integration" "upstream/main" - -# Return to whatever branch we were on -git -C "$SABLE_DIR" checkout "$CURRENT_BRANCH" 2>/dev/null || true - -# ── 3. Git signing (SSH via forwarded YubiKey) ──────────────────────────────── -echo "==> [post-create] Configuring SSH commit signing..." -bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true - -# ── 4. Install git hooks ────────────────────────────────────────────────────── -echo "==> [post-create] Installing git hooks..." -if [ -f "$SABLE_DIR/scripts/install-git-hooks.sh" ]; then - bash "$SABLE_DIR/scripts/install-git-hooks.sh" -fi - -echo "" -echo "==> [post-create] Done! Open sable.code-workspace for the multi-root view." -echo " Run '.devcontainer/setup-signing.sh' any time to reconfigure commit signing." diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh deleted file mode 100755 index f2353f39f..000000000 --- a/.devcontainer/post-start.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -# post-start.sh — runs on EVERY codespace start. -# Fetches upstream changes and re-checks signing (agent may have changed). -set -euo pipefail - -SABLE_DIR="/workspaces/Sable" -DOCS_DIR="/workspaces/Sable-Docs" - -# ── Fetch upstream for both repos ──────────────────────────────────────────── -echo "==> [post-start] Fetching upstream..." -git -C "$SABLE_DIR" fetch upstream --quiet 2>/dev/null && echo " Sable upstream fetched" || echo " ⚠ Could not fetch Sable upstream" -git -C "$DOCS_DIR" fetch upstream --quiet 2>/dev/null && echo " Docs upstream fetched" || echo " ⚠ Could not fetch Docs upstream" - -# ── Show how far behind integration is from upstream/dev ───────────────────── -BEHIND=$(git -C "$SABLE_DIR" rev-list --count HEAD..upstream/dev 2>/dev/null || echo "?") -if [ "$BEHIND" != "0" ] && [ "$BEHIND" != "?" ]; then - echo "" - echo " ℹ Your current branch is $BEHIND commit(s) behind upstream/dev." - echo " To sync: git merge upstream/dev (or: git rebase upstream/dev)" -fi - -# ── Re-configure SSH signing if not already set (agent may now be available) ─ -CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" -if [ "$(git config --global gpg.format 2>/dev/null)" != "ssh" ]; then - bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true -else - # Verify the signing key is still accessible - CONFIGURED_KEY=$(git config --global user.signingkey 2>/dev/null || echo "") - if [ -n "$CONFIGURED_KEY" ]; then - # If it's a file path (MODE 2), check file exists - if [[ "$CONFIGURED_KEY" == /* ]]; then - if [ -f "$CONFIGURED_KEY" ]; then - echo " ✓ Commit signing ready (private key file)" - else - echo " ⚠ Signing key file not found: $CONFIGURED_KEY" - echo " Re-run: bash .devcontainer/setup-signing.sh" - fi - # If it's a public key string (MODE 1), check agent - else - if ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then - echo " ✓ Commit signing ready (forwarded agent)" - else - echo " ⚠ Signing key not in SSH agent. YubiKey present?" - echo " Re-run: bash .devcontainer/setup-signing.sh" - fi - fi - fi -fi - -echo "" diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh deleted file mode 100755 index ca5866095..000000000 --- a/.devcontainer/setup-signing.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# setup-signing.sh — configures SSH commit signing. -# Supports two modes: -# 1. Forwarded SSH agent (VS Code desktop + YubiKey) -# 2. Codespace-local SSH key (browser/web Codespaces) -# Safe to re-run at any time. -set -euo pipefail - -SABLE_DIR="/workspaces/Sable" -ALLOWED_SIGNERS_FILE="$HOME/.config/git/allowed_signers" -CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" - -# ── MODE 1: Forwarded SSH agent (desktop VS Code) ──────────────────────────── -if ssh-add -L &>/dev/null && [ -n "$(ssh-add -L 2>/dev/null)" ]; then - echo "✓ Detected forwarded SSH agent (desktop VS Code + YubiKey mode)" - SIGNING_KEY=$(ssh-add -L | head -1) - KEY_COMMENT=$(echo "$SIGNING_KEY" | awk '{print $NF}') - echo " Using key: ...${KEY_COMMENT}" - -# ── MODE 2: Codespace-local key (web Codespaces) ───────────────────────────── -else - echo "ℹ No forwarded agent (web Codespace mode)" - - if [ ! -f "$CODESPACE_KEY" ]; then - echo " Generating new Ed25519 signing key..." - mkdir -p "$HOME/.ssh" - ssh-keygen -t ed25519 -f "$CODESPACE_KEY" -N "" -C "codespace-signing@$(hostname)" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " 🔑 Add this PUBLIC KEY to GitHub as a SIGNING key:" - echo "" - cat "${CODESPACE_KEY}.pub" - echo "" - echo " 👉 https://github.com/settings/keys → New SSH key" - echo " Title: Codespace Signing Key" - echo " Key type: Signing Key" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - read -p "Press Enter after adding the key to GitHub..." - fi - - # Use the private key file directly (git supports this without ssh-agent) - SIGNING_KEY="$CODESPACE_KEY" - echo " Using Codespace key: ${CODESPACE_KEY}" -fi - -# ── Common: Configure git ──────────────────────────────────────────────────── -git config --global gpg.format ssh -git config --global user.signingkey "$SIGNING_KEY" -git config --global commit.gpgsign true -git config --global tag.gpgsign true - -# Set up allowed_signers for local verification -USER_EMAIL=$(git config --global user.email 2>/dev/null || echo "") -if [ -n "$USER_EMAIL" ]; then - mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")" - if [ -f "$ALLOWED_SIGNERS_FILE" ]; then - grep -v "^$USER_EMAIL " "$ALLOWED_SIGNERS_FILE" > "${ALLOWED_SIGNERS_FILE}.tmp" || true - mv "${ALLOWED_SIGNERS_FILE}.tmp" "$ALLOWED_SIGNERS_FILE" - fi - - # For allowed_signers, always use the public key (even if signing with private key file) - if [ -f "$CODESPACE_KEY" ]; then - # MODE 2: read public key from file - PUBLIC_KEY=$(cat "${CODESPACE_KEY}.pub") - else - # MODE 1: already have public key in $SIGNING_KEY - PUBLIC_KEY="$SIGNING_KEY" - fi - - echo "$USER_EMAIL namespaces=\"git\" $PUBLIC_KEY" >> "$ALLOWED_SIGNERS_FILE" - git config --global gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS_FILE" - echo "✓ SSH commit signing configured for <$USER_EMAIL>" -else - echo "⚠ user.email not set globally. Run: git config --global user.email 'you@example.com'" - echo " Then re-run: bash .devcontainer/setup-signing.sh" -fi - -echo "" -echo "Test signing: git commit --allow-empty -m 'test signing'" -echo "Verify: git log --show-signature -1" \ No newline at end of file diff --git a/.devcontainer/update-content.sh b/.devcontainer/update-content.sh deleted file mode 100755 index 572ae73ba..000000000 --- a/.devcontainer/update-content.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# update-content.sh — runs on each prebuild refresh AND on new codespace creation. -# The resulting filesystem state is cached in the prebuild snapshot. -set -euo pipefail - -echo "==> [update-content] Installing Sable dependencies (pnpm install)..." -pnpm install --frozen-lockfile - -echo "==> [update-content] Cloning / updating Sable-Docs..." -DOCS_DIR="/workspaces/Sable-Docs" -if [ -d "$DOCS_DIR/.git" ]; then - echo " Docs already present, fetching latest..." - git -C "$DOCS_DIR" fetch --all -else - echo " Cloning Just-Insane/docs → $DOCS_DIR" - git clone https://github.com/Just-Insane/docs "$DOCS_DIR" -fi - -echo "==> [update-content] Done." From 3786c22072fafabf021166a1117fe3769ed1c969 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 17:25:18 -0400 Subject: [PATCH 33/68] Revise GitHub Copilot workspace instructions Updated instructions for pull requests and feature flags. --- .github/copilot-instructions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 882847e80..1b7ec036c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ # Sable – GitHub Copilot Workspace Instructions -These rules apply to every chat and agent session in this workspace. +These rules apply to every chat and agent session in this workspace. Follow all rules that follow while responding to chat requests. --- @@ -34,7 +34,7 @@ pnpm build ## Pull Requests - Use the upstream PR template ([`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. -- Descriptions should be short, clear, and human-readable. No AI-generated explanations in the AI disclosure section. +- Descriptions should be short, clear, and human-readable. - Each PR gets **one changeset line** (or one `fix:` + one `feat:` if both are genuinely present, though prefer separate PRs). - PRs must not target `dev` directly without a reviewed branch. - Before opening a PR, **search for related open and merged PRs on both `upstream` (SableClient/Sable or cinnyapp/cinny) and `origin`**. Review them to understand what else may be in flight that could affect the change. Summarise any findings and ask the user how to proceed if there is overlap or conflict. @@ -51,7 +51,7 @@ pnpm build - Every user-visible new feature must be gated behind a **feature flag** in `config.json` / `useClientConfig`. - Flags default to `false` (opt-in) unless the feature is a bug fix or a non-breaking improvement with no regressions. -- Document the flag in `docs/sample.env` and in the Sable-Docs documentation repo. +- Document the flag in `config.json` and in the Sable-Docs documentation repo. ## Code Quality From 361317697c0e52730375a82d26530b7cdacb0004 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 18:07:14 -0400 Subject: [PATCH 34/68] Update branching instructions for syncing with upstream Added instructions for syncing branches before creating a new branch. --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1b7ec036c..10a5a8b50 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,6 +7,7 @@ These rules apply to every chat and agent session in this workspace. Follow all ## Git & Branching - **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). + - When creating a branch, always sync `upstream/dev` to `origin/dev` and `dev`, and build the branch from `dev` - Before building `integration`, always **force-update `dev` from `upstream/dev`**: ``` git fetch upstream && git checkout dev && git reset --hard upstream/dev From 4ae9f6e5bb882a551d718c04f975921a4381092e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 13:29:02 -0400 Subject: [PATCH 35/68] Revise instructions for clarity and consistency Updated wording for clarity and consistency in instructions. --- .github/copilot-instructions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 10a5a8b50..e23b792a5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,13 +1,13 @@ # Sable – GitHub Copilot Workspace Instructions -These rules apply to every chat and agent session in this workspace. Follow all rules that follow while responding to chat requests. +These rules apply to every chat and agent session in this workspace. Follow all instructions below while responding to chat requests. --- ## Git & Branching - **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). - - When creating a branch, always sync `upstream/dev` to `origin/dev` and `dev`, and build the branch from `dev` + - When creating a branch, always sync `upstream/dev` to `origin/dev` and `dev`, and then build the branch from `dev` - Before building `integration`, always **force-update `dev` from `upstream/dev`**: ``` git fetch upstream && git checkout dev && git reset --hard upstream/dev From 6d2a64ea20c63fe2c03f6d135ee04f775028d6a2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 13:32:23 -0400 Subject: [PATCH 36/68] Move `copilot-instructions.md` to correct location --- .github/copilot-instructions.md => copilot-instructions.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/copilot-instructions.md => copilot-instructions.md (100%) diff --git a/.github/copilot-instructions.md b/copilot-instructions.md similarity index 100% rename from .github/copilot-instructions.md rename to copilot-instructions.md From 25f1ccfad13e4ad393d6732666a42a25938da6b8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 13:41:11 -0400 Subject: [PATCH 37/68] Clarify branch creation and PR instructions --- copilot-instructions.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/copilot-instructions.md b/copilot-instructions.md index e23b792a5..bf4716e92 100644 --- a/copilot-instructions.md +++ b/copilot-instructions.md @@ -7,12 +7,12 @@ These rules apply to every chat and agent session in this workspace. Follow all ## Git & Branching - **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). - - When creating a branch, always sync `upstream/dev` to `origin/dev` and `dev`, and then build the branch from `dev` + - When creating a branch (i.e. if a branch for the requested change doesn't exist or there isn't an existing branch that fits), always sync `upstream/dev` to `origin/dev` and `dev`, and then build the branch from `dev` - Before building `integration`, always **force-update `dev` from `upstream/dev`**: ``` git fetch upstream && git checkout dev && git reset --hard upstream/dev ``` -- When asked to build `integration`, **always prompt for which feature/fix branches to include**. If a needed branch doesn't exist yet, create it first. +- When asked to build `integration`, **always prompt for which feature/fix branches to include**. In general, all feat/fix/chore/etc branches should be inlcuded. - Use short, scoped commit messages: `type(scope): description` (e.g. `fix(timeline): correct scroll anchor on bulk load`). ## Quality Gates (must pass before every commit) @@ -34,10 +34,9 @@ pnpm build ## Pull Requests -- Use the upstream PR template ([`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. +- Use the upstream PR template (i.e. [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. - Descriptions should be short, clear, and human-readable. - Each PR gets **one changeset line** (or one `fix:` + one `feat:` if both are genuinely present, though prefer separate PRs). -- PRs must not target `dev` directly without a reviewed branch. - Before opening a PR, **search for related open and merged PRs on both `upstream` (SableClient/Sable or cinnyapp/cinny) and `origin`**. Review them to understand what else may be in flight that could affect the change. Summarise any findings and ask the user how to proceed if there is overlap or conflict. - Before opening a PR, **search for related open issues on both `upstream` and `origin`**. If any are related, prompt the user to confirm, then link them in the PR description (`Closes #N` / `Related to #N`). - If the PR has a corresponding `SableClient/docs` PR, link both PRs to each other in their descriptions. @@ -60,6 +59,7 @@ pnpm build - No `any` casts without a comment explaining why it's unavoidable. - Comments must be **short and purposeful** — explain *why*, not *what*. No decorative separator lines (`//------`), no block comments restating the code. - Do not add docstrings, comments, or type annotations to code that wasn't changed in the current task. +- Add concise docstrings, comments, and/or type annotations on updating/new code in the current task. - Prefer explicit types over inferred types for public function signatures. ## Documentation From d417c88a972d9655b7c3682dfe59a4bc2f39259b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 15:22:58 -0400 Subject: [PATCH 38/68] Docs have this location too... --- copilot-instructions.md => .github/copilot-instructions.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename copilot-instructions.md => .github/copilot-instructions.md (100%) diff --git a/copilot-instructions.md b/.github/copilot-instructions.md similarity index 100% rename from copilot-instructions.md rename to .github/copilot-instructions.md From bb05e945ae5f3daf8c704e68b2298390598fb05a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 10:55:38 -0400 Subject: [PATCH 39/68] chore(config): split copilot-instructions into scoped instruction files and AGENTS.md --- .github/copilot-instructions.md | 85 ++----------------- .github/instructions/security.instructions.md | 10 +++ .../instructions/typescript.instructions.md | 29 +++++++ AGENTS.md | 73 ++++++++++++++++ 4 files changed, 121 insertions(+), 76 deletions(-) create mode 100644 .github/instructions/security.instructions.md create mode 100644 .github/instructions/typescript.instructions.md create mode 100644 AGENTS.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bf4716e92..401bc55cb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,83 +1,16 @@ -# Sable – GitHub Copilot Workspace Instructions +# Sable – GitHub Copilot Instructions -These rules apply to every chat and agent session in this workspace. Follow all instructions below while responding to chat requests. +Universal rules that apply to every session. Detailed guidance lives in `.github/instructions/` and `AGENTS.md`. ---- - -## Git & Branching +## Core Rules - **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). - - When creating a branch (i.e. if a branch for the requested change doesn't exist or there isn't an existing branch that fits), always sync `upstream/dev` to `origin/dev` and `dev`, and then build the branch from `dev` -- Before building `integration`, always **force-update `dev` from `upstream/dev`**: +- Use short, scoped commit messages: `type(scope): description` (e.g. `fix(timeline): correct scroll anchor on bulk load`). +- Run quality gates in order and fix all failures before committing: ``` - git fetch upstream && git checkout dev && git reset --hard upstream/dev + pnpm lint && pnpm fmt:check && pnpm typecheck && pnpm test:run && pnpm knip && pnpm build ``` -- When asked to build `integration`, **always prompt for which feature/fix branches to include**. In general, all feat/fix/chore/etc branches should be inlcuded. -- Use short, scoped commit messages: `type(scope): description` (e.g. `fix(timeline): correct scroll anchor on bulk load`). - -## Quality Gates (must pass before every commit) - -Run these in order and fix all failures before committing: - -``` -pnpm lint # ESLint -pnpm fmt:check # Prettier -pnpm typecheck # TypeScript -pnpm test:run # Vitest unit tests -pnpm knip # Dead-code / unused exports check -``` - -Also run a **production build** and confirm it succeeds with no errors: -``` -pnpm build -``` - -## Pull Requests - -- Use the upstream PR template (i.e. [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. -- Descriptions should be short, clear, and human-readable. -- Each PR gets **one changeset line** (or one `fix:` + one `feat:` if both are genuinely present, though prefer separate PRs). -- Before opening a PR, **search for related open and merged PRs on both `upstream` (SableClient/Sable or cinnyapp/cinny) and `origin`**. Review them to understand what else may be in flight that could affect the change. Summarise any findings and ask the user how to proceed if there is overlap or conflict. -- Before opening a PR, **search for related open issues on both `upstream` and `origin`**. If any are related, prompt the user to confirm, then link them in the PR description (`Closes #N` / `Related to #N`). -- If the PR has a corresponding `SableClient/docs` PR, link both PRs to each other in their descriptions. - -## Matrix Spec Compliance - -- New features and fixes must match the **current Matrix spec** or the relevant **MSC** if the spec change is pending. -- Check how **Element Web**, **FluffyChat**, or **Nheko** implement the same thing before diverging from established client patterns. -- Link the relevant spec section or MSC in the PR description when the change is spec-driven. - -## Feature Flags - -- Every user-visible new feature must be gated behind a **feature flag** in `config.json` / `useClientConfig`. -- Flags default to `false` (opt-in) unless the feature is a bug fix or a non-breaking improvement with no regressions. -- Document the flag in `config.json` and in the Sable-Docs documentation repo. - -## Code Quality - -- Code must follow **TypeScript/React best practices**: functional components, hooks, no class components, proper dependency arrays on `useEffect`/`useCallback`/`useMemo`. -- No `any` casts without a comment explaining why it's unavoidable. -- Comments must be **short and purposeful** — explain *why*, not *what*. No decorative separator lines (`//------`), no block comments restating the code. -- Do not add docstrings, comments, or type annotations to code that wasn't changed in the current task. -- Add concise docstrings, comments, and/or type annotations on updating/new code in the current task. -- Prefer explicit types over inferred types for public function signatures. - -## Documentation - -- When a new feature is added (or an existing one materially changed), **update the Sable-Docs repo** (`/Users/evie/git/Sable-Docs`). Add or update the relevant page under `content/features/` or `content/general/`. -- Keep docs concise — match the style of existing pages. - -## Security - -- Follow OWASP Top 10 guidance. No `innerHTML`, no `eval`, sanitise all user/Matrix-sourced content before rendering. -- Do not log or expose access tokens, room keys, or other secrets. -- Content Security Policy headers (Caddyfile / Dockerfile) must not be weakened without a documented reason. - -## Additional Rules - +- No `any` casts without an inline comment explaining why it's unavoidable. - **No over-engineering**: only make changes directly requested or clearly necessary. Don't add abstractions for one-off operations. -- **Reversible actions only**: ask before deleting files/branches, force-pushing, or dropping data. -- **Dependency changes** (adding/removing packages) require explicit confirmation before running `pnpm install`. -- When resolving merge conflicts, prefer the version from the feature branch; ask if the intent is ambiguous. -- Test files live alongside source in `src/` (e.g. `*.test.ts`). Match the naming convention of existing tests. -- **Write tests when needed**: any new utility function, hook, or non-trivial logic should have a corresponding Vitest test. Bug fixes should include a regression test where feasible. +- **Reversible actions only**: ask before deleting files/branches, force-pushing, dropping data, or running `pnpm install` to add/remove packages. +- Do not log or expose access tokens, room keys, or other secrets. diff --git a/.github/instructions/security.instructions.md b/.github/instructions/security.instructions.md new file mode 100644 index 000000000..9586e7e1f --- /dev/null +++ b/.github/instructions/security.instructions.md @@ -0,0 +1,10 @@ +--- +applyTo: "src/**,Caddyfile,Dockerfile" +--- + +## Security + +- Follow OWASP Top 10 guidance. +- No `innerHTML`, no `eval`; sanitise all user-supplied and Matrix-sourced content before rendering. +- Do not log or expose access tokens, room keys, or other secrets. +- Content Security Policy headers in `Caddyfile` and `Dockerfile` must not be weakened without a documented reason. diff --git a/.github/instructions/typescript.instructions.md b/.github/instructions/typescript.instructions.md new file mode 100644 index 000000000..4ea1a1ac3 --- /dev/null +++ b/.github/instructions/typescript.instructions.md @@ -0,0 +1,29 @@ +--- +applyTo: "src/**" +--- + +## TypeScript & React + +- Functional components and hooks only. No class components. +- Proper dependency arrays on `useEffect`, `useCallback`, and `useMemo`. +- Prefer explicit types over inferred types for public/exported function signatures. +- No `any` casts without an inline comment explaining why it's unavoidable. + +## Comments & Documentation + +- Comments must be **short and purposeful** — explain *why*, not *what*. +- No decorative separator lines (`//------`), no block comments restating the code. +- Do not add docstrings, comments, or type annotations to code that was not changed in the current task. +- Add concise docstrings, comments, and/or type annotations to new or updated code. + +## Testing + +- Test files live alongside source in `src/` (e.g. `foo.test.ts`). Match the naming convention of existing tests. +- Write Vitest tests for any new utility function, hook, or non-trivial logic. +- Bug fixes should include a regression test where feasible. + +## Feature Flags + +- Every user-visible new feature must be gated behind a feature flag in `config.json` / `useClientConfig`. +- Flags default to `false` (opt-in) unless the feature is a bug fix or a non-breaking improvement with no regressions. +- Document the flag in `config.json` and in the Sable-Docs documentation repo. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..62c3f8d99 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# Sable – Agent Instructions + +Workflow and process rules for AI agents. These complement the universal rules in `.github/copilot-instructions.md`. + +--- + +## Git & Branching + +- Never commit directly to `dev` or `integration`. +- When creating a branch, first sync `upstream/dev` to `origin/dev` and local `dev`, then branch from `dev`: + ``` + git fetch upstream + git checkout dev && git reset --hard upstream/dev + git push origin dev + git checkout -b feat/your-branch dev + ``` +- Before building `integration`, always force-update `dev` from `upstream/dev`: + ``` + git fetch upstream && git checkout dev && git reset --hard upstream/dev + ``` +- When asked to build `integration`, always prompt for which feature/fix/chore branches to include. In general, include all non-`dev` branches. + +## Quality Gates + +Run these in order and fix all failures before committing: + +``` +pnpm lint # ESLint +pnpm fmt:check # Prettier +pnpm typecheck # TypeScript +pnpm test:run # Vitest unit tests +pnpm knip # Dead-code / unused exports check +pnpm build # Production build — must succeed with no errors +``` + +## Pull Requests + +- Use the PR template (`.github/PULL_REQUEST_TEMPLATE.md`) in full — all checkboxes must be present. +- Descriptions should be short, clear, and human-readable. +- Each PR gets one changeset line (or `fix:` + `feat:` if both are genuinely present; prefer separate PRs otherwise). + +### Pre-PR Research + +1. Search for related open **and** merged PRs on `upstream` (`SableClient/Sable` and `cinnyapp/cinny`) and `origin`. Summarise findings and ask how to proceed if there is overlap or conflict. +2. Search for related open **issues** on `upstream` and `origin`. Confirm with the user, then link any related ones in the PR description (`Closes #N` / `Related to #N`). +3. If the PR has a corresponding `SableClient/docs` PR, link both PRs to each other. + +## Matrix Spec Compliance + +- New features and fixes must match the current Matrix spec, or the relevant MSC if the spec change is pending. +- Check how Element Web, FluffyChat, or Nheko implement the same thing before diverging from established client patterns. +- Link the relevant spec section or MSC in the PR description when the change is spec-driven. + +## Documentation + +- When a new feature is added (or an existing one materially changed), update the Sable-Docs repo (`/Users/evie/git/Sable-Docs`). Add or update the relevant page under `content/features/` or `content/general/`. +- Keep docs concise — match the style of existing pages. + +## Dependency Changes + +- Adding or removing packages requires explicit user confirmation before running `pnpm install`. + +## Merge Conflicts + +- When resolving merge conflicts, prefer the version from the feature branch; ask if the intent is ambiguous. + +## Destructive Actions + +Always ask before: +- Deleting files or branches (`git branch -D`, `rm`, etc.) +- Force-pushing (`git push --force`) +- Hard-resetting local branches other than `dev`/`integration` (`git reset --hard`) +- Dropping or truncating data From 3a5351f7f0d5ee0aeb3357f59e5a67f28aae0dc8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 11:02:00 -0400 Subject: [PATCH 40/68] Update git instructions in AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 62c3f8d99..266aa22d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Workflow and process rules for AI agents. These complement the universal rules i ## Git & Branching - Never commit directly to `dev` or `integration`. -- When creating a branch, first sync `upstream/dev` to `origin/dev` and local `dev`, then branch from `dev`: +- When creating a branch, first sync `upstream/dev` to `origin/dev` and local `dev`, then branch from `dev`, with `origin/dev` as the remote: ``` git fetch upstream git checkout dev && git reset --hard upstream/dev From aa3d17d9e724c95961cbac7e5d07b80d9fa7c829 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 11:42:00 -0400 Subject: [PATCH 41/68] Update git commands --- AGENTS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 266aa22d2..c44ee7052 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,9 @@ Workflow and process rules for AI agents. These complement the universal rules i git push origin dev git checkout -b feat/your-branch dev ``` -- Before building `integration`, always force-update `dev` from `upstream/dev`: +- Before building `integration`, always force-update `origin/dev` from `upstream/dev`, then force-update `dev`: ``` - git fetch upstream && git checkout dev && git reset --hard upstream/dev + git fetch upstream && git push origin upstream/dev:dev --force && git fetch origin && git checkout dev && git reset --hard origin/dev ``` - When asked to build `integration`, always prompt for which feature/fix/chore branches to include. In general, include all non-`dev` branches. @@ -67,6 +67,7 @@ pnpm build # Production build — must succeed with no errors ## Destructive Actions Always ask before: + - Deleting files or branches (`git branch -D`, `rm`, etc.) - Force-pushing (`git push --force`) - Hard-resetting local branches other than `dev`/`integration` (`git reset --hard`) From ccdf3ef305e8d9a3f89c749a52e2401f8b0d7f18 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 19:28:26 +0000 Subject: [PATCH 42/68] chore(codespace): add devcontainer for iPad browser + SSH signing --- .devcontainer/devcontainer.json | 90 +++++++++++++++++++++++++++++++++ .devcontainer/postCreate.sh | 65 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/postCreate.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..30f6a2a33 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,90 @@ +// Codespace configuration — lives on personal/config (not ephemeral dev/feat branches). +// This file intentionally targets browser-based use on iPad. +{ + "name": "Sable", + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm", + + "features": { + // Keep git up-to-date for SSH signing support (git ≥ 2.34). + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + + // ── Codespace user secrets ────────────────────────────────────────────────── + // Configure these at: github.com/settings/codespaces > Secrets + // + // GIT_SIGNING_KEY — passphrase-free SSH private key (ed25519 recommended). + // Add the matching public key to your GitHub account as a + // "signing key": github.com/settings/keys + // postCreate.sh will wire up git automatically if set. + // + // GIT_USER_NAME — e.g. "Evie" + // GIT_USER_EMAIL — e.g. "you@example.com" + // ─────────────────────────────────────────────────────────────────────────── + + "remoteEnv": { + // Pin the pnpm store to a known path so the volume mount works across rebuilds. + "PNPM_STORE_DIR": "/home/node/.pnpm-store" + }, + + "customizations": { + "vscode": { + "settings": { + // ── Layout — tuned for iPad browser (vscode.dev / Codespaces web) ───── + // Move the activity bar to the top so it isn't hidden by the iOS Safari + // toolbar or the browser's combined title/status bar. + "workbench.activityBar.location": "top", + // Use a menu for the layout control — fewer tiny hit targets on touch. + "workbench.layoutControl.type": "menu", + // Place the panel (Terminal, Problems, Copilot Chat history) on the + // right so it doesn't fight with the keyboard on small screens. + "workbench.panel.defaultLocation": "right", + // Keep editor tabs visible and wrap them so none are hidden off-screen. + "workbench.editor.showTabs": "multiple", + "workbench.editor.wrapTabs": true, + // Disable minimap — saves horizontal space, improves touch accuracy. + "editor.minimap.enabled": false, + "editor.scrollBeyondLastLine": false, + // Larger default fonts for retina/HiDPI iPad displays. + "editor.fontSize": 14, + "terminal.integrated.fontSize": 14, + + // ── Git signing ─────────────────────────────────────────────────────── + // postCreate.sh configures gpg.format and user.signingkey if + // GIT_SIGNING_KEY secret is present. This just keeps VS Code's git + // UI in sync. + "git.enableCommitSigning": true, + "git.confirmSync": false, + + // ── Copilot Chat ────────────────────────────────────────────────────── + // Always show follow-ups and keep chat history accessible. + "github.copilot.chat.followUps": "always" + }, + "extensions": [ + "GitHub.copilot", + "GitHub.copilot-chat", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "vitest.explorer" + ] + } + }, + + // ── Port forwarding ───────────────────────────────────────────────────────── + "forwardPorts": [5173, 4173], + "portsAttributes": { + "5173": { "label": "Vite dev", "onAutoForward": "notify" }, + "4173": { "label": "Vite preview", "onAutoForward": "notify" } + }, + + // ── Persistence ───────────────────────────────────────────────────────────── + // Named volume keeps the pnpm content-addressable store across rebuilds. + // Combined with the PNPM_STORE_DIR env var above so postCreate can also + // point pnpm at the same path. + "mounts": [ + "source=sable-pnpm-store,target=/home/node/.pnpm-store,type=volume" + ], + + "postCreateCommand": "bash .devcontainer/postCreate.sh", + "remoteUser": "node" +} diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh new file mode 100644 index 000000000..f6c539985 --- /dev/null +++ b/.devcontainer/postCreate.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# postCreate.sh — runs once after the Codespace container is created. +set -euo pipefail + +# ── pnpm ────────────────────────────────────────────────────────────────────── +# Enable corepack so the exact pnpm version from package.json#packageManager is used. +corepack enable + +# Point pnpm at the persistent named-volume store so packages survive rebuilds. +if [ -n "${PNPM_STORE_DIR:-}" ]; then + pnpm config set store-dir "${PNPM_STORE_DIR}" +fi + +pnpm install + +# ── Git identity ────────────────────────────────────────────────────────────── +# Populate from Codespace user secrets if they aren't already set by dotfiles. +if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then + git config --global user.name "${GIT_USER_NAME}" +fi + +if [ -n "${GIT_USER_EMAIL:-}" ] && [ -z "$(git config --global user.email 2>/dev/null)" ]; then + git config --global user.email "${GIT_USER_EMAIL}" +fi + +# ── Git SSH commit signing ──────────────────────────────────────────────────── +# Requires a Codespace user secret named GIT_SIGNING_KEY containing a +# passphrase-free SSH private key (ed25519 recommended). +# +# To set up: +# 1. Generate a key: ssh-keygen -t ed25519 -C "codespace signing" -N "" -f ~/.ssh/signing_key +# 2. Copy the private key into a GitHub Codespace secret called GIT_SIGNING_KEY: +# github.com/settings/codespaces > Secrets > New secret +# 3. Add the *public* key to your GitHub account as a signing key (not auth key): +# github.com/settings/keys > New signing key +# ---------------------------------------------------------------------------- +if [ -n "${GIT_SIGNING_KEY:-}" ]; then + SSH_DIR="${HOME}/.ssh" + mkdir -p "${SSH_DIR}" + chmod 700 "${SSH_DIR}" + + KEY_FILE="${SSH_DIR}/git_signing_key" + printf '%s\n' "${GIT_SIGNING_KEY}" > "${KEY_FILE}" + chmod 600 "${KEY_FILE}" + + # Derive the public key from the private key so the user only stores one secret. + ssh-keygen -y -f "${KEY_FILE}" > "${KEY_FILE}.pub" + chmod 644 "${KEY_FILE}.pub" + + # Configure git to use SSH signing. + git config --global gpg.format ssh + git config --global user.signingkey "${KEY_FILE}.pub" + git config --global commit.gpgsign true + git config --global tag.gpgsign true + + # Allow this key when verifying signatures locally. + ALLOWED_SIGNERS="${SSH_DIR}/allowed_signers" + EMAIL="$(git config --global user.email 2>/dev/null || echo "you@example.com")" + echo "${EMAIL} $(cat "${KEY_FILE}.pub")" > "${ALLOWED_SIGNERS}" + git config --global gpg.ssh.allowedSignersFile "${ALLOWED_SIGNERS}" + + echo "✓ Git SSH commit signing configured (${KEY_FILE}.pub)" +fi + +echo "✓ postCreate complete" From 0b9e539676367a536994c3c1c497b1fb8cc2e4ad Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 19:43:17 +0000 Subject: [PATCH 43/68] chore(codespace): add Fira Code font + ligatures --- .devcontainer/devcontainer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 30f6a2a33..5a9c48160 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -47,7 +47,10 @@ "editor.scrollBeyondLastLine": false, // Larger default fonts for retina/HiDPI iPad displays. "editor.fontSize": 14, + "editor.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", + "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, + "terminal.integrated.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", // ── Git signing ─────────────────────────────────────────────────────── // postCreate.sh configures gpg.format and user.signingkey if @@ -63,6 +66,7 @@ "extensions": [ "GitHub.copilot", "GitHub.copilot-chat", + "tonsky.font-fira-code", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "vitest.explorer" From 5ea86ae2197c05c5dfb298b6192a80879a286152 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 19:45:50 +0000 Subject: [PATCH 44/68] chore(codespace): split onCreate/postCreate for prebuild caching --- .devcontainer/devcontainer.json | 1 + .devcontainer/onCreate.sh | 16 ++++++++++++++++ .devcontainer/postCreate.sh | 14 ++------------ 3 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 .devcontainer/onCreate.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5a9c48160..94864c6fb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -90,5 +90,6 @@ ], "postCreateCommand": "bash .devcontainer/postCreate.sh", + "onCreateCommand": "bash .devcontainer/onCreate.sh", "remoteUser": "node" } diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh new file mode 100644 index 000000000..dcb012cd0 --- /dev/null +++ b/.devcontainer/onCreate.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# onCreate.sh — runs during prebuild AND on first Codespace creation. +# No user secrets are available here — keep this purely about dependencies. +set -euo pipefail + +# Enable corepack so the exact pnpm version from package.json#packageManager is used. +corepack enable + +# Point pnpm at the persistent named-volume store so packages survive rebuilds. +if [ -n "${PNPM_STORE_DIR:-}" ]; then + pnpm config set store-dir "${PNPM_STORE_DIR}" +fi + +pnpm install + +echo "✓ onCreate complete" diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index f6c539985..1d76c8e70 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -1,18 +1,8 @@ #!/bin/bash -# postCreate.sh — runs once after the Codespace container is created. +# postCreate.sh — runs once after the Codespace container is created (NOT during prebuild). +# Secrets (GIT_SIGNING_KEY, GIT_USER_NAME, GIT_USER_EMAIL) are available here. set -euo pipefail -# ── pnpm ────────────────────────────────────────────────────────────────────── -# Enable corepack so the exact pnpm version from package.json#packageManager is used. -corepack enable - -# Point pnpm at the persistent named-volume store so packages survive rebuilds. -if [ -n "${PNPM_STORE_DIR:-}" ]; then - pnpm config set store-dir "${PNPM_STORE_DIR}" -fi - -pnpm install - # ── Git identity ────────────────────────────────────────────────────────────── # Populate from Codespace user secrets if they aren't already set by dotfiles. if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then From 946434d2c798ed6ab1ee66762e365bf08b60faad Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 19:58:56 +0000 Subject: [PATCH 45/68] chore(codespace): fix image tag, install OMZ+P10k, wire dotfiles bare-repo --- .devcontainer/devcontainer.json | 13 +++++++++---- .devcontainer/onCreate.sh | 26 ++++++++++++++++++++++++++ .devcontainer/postCreate.sh | 29 +++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 94864c6fb..f5223c3cb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,9 +2,12 @@ // This file intentionally targets browser-based use on iPad. { "name": "Sable", - "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm", + // Using base + node feature instead of javascript-node: to avoid + // tag availability issues on newer Node versions. + "image": "mcr.microsoft.com/devcontainers/base:bookworm", "features": { + "ghcr.io/devcontainers/features/node:1": { "version": "24" }, // Keep git up-to-date for SSH signing support (git ≥ 2.34). "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {} @@ -24,7 +27,7 @@ "remoteEnv": { // Pin the pnpm store to a known path so the volume mount works across rebuilds. - "PNPM_STORE_DIR": "/home/node/.pnpm-store" + "PNPM_STORE_DIR": "/home/vscode/.pnpm-store" }, "customizations": { @@ -51,6 +54,8 @@ "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, "terminal.integrated.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", + // Use zsh (installed in onCreate) as the default terminal shell. + "terminal.integrated.defaultProfile.linux": "zsh", // ── Git signing ─────────────────────────────────────────────────────── // postCreate.sh configures gpg.format and user.signingkey if @@ -86,10 +91,10 @@ // Combined with the PNPM_STORE_DIR env var above so postCreate can also // point pnpm at the same path. "mounts": [ - "source=sable-pnpm-store,target=/home/node/.pnpm-store,type=volume" + "source=sable-pnpm-store,target=/home/vscode/.pnpm-store,type=volume" ], "postCreateCommand": "bash .devcontainer/postCreate.sh", "onCreateCommand": "bash .devcontainer/onCreate.sh", - "remoteUser": "node" + "remoteUser": "vscode" } diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index dcb012cd0..bc2d2a967 100644 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -1,8 +1,10 @@ #!/bin/bash # onCreate.sh — runs during prebuild AND on first Codespace creation. # No user secrets are available here — keep this purely about dependencies. +# Everything here is cached in the prebuild snapshot. set -euo pipefail +# ── pnpm ────────────────────────────────────────────────────────────────────── # Enable corepack so the exact pnpm version from package.json#packageManager is used. corepack enable @@ -13,4 +15,28 @@ fi pnpm install +# ── Zsh + Oh My Zsh + Powerlevel10k ────────────────────────────────────────── +# Install these during prebuild so the first Codespace start is fast. +# The dotfiles checkout in postCreate.sh will provide .zshrc / .p10k.zsh. + +# Install zsh if not already present (base:bookworm ships it, but be safe). +if ! command -v zsh &>/dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq zsh +fi + +# Install Oh My Zsh non-interactively (KEEP_ZSHRC=yes preserves any existing .zshrc). +if [ ! -d "${HOME}/.oh-my-zsh" ]; then + KEEP_ZSHRC=yes CHSH=no RUNZSH=no \ + sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" +fi + +# Install Powerlevel10k as an OMZ custom theme. +P10K_DIR="${ZSH_CUSTOM:-${HOME}/.oh-my-zsh/custom}/themes/powerlevel10k" +if [ ! -d "${P10K_DIR}" ]; then + git clone --depth=1 https://github.com/romkatv/powerlevel10k.git "${P10K_DIR}" +fi + +# Make zsh the default shell for this user. +sudo chsh -s "$(command -v zsh)" "$(whoami)" + echo "✓ onCreate complete" diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 1d76c8e70..8d9a404f7 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -3,6 +3,35 @@ # Secrets (GIT_SIGNING_KEY, GIT_USER_NAME, GIT_USER_EMAIL) are available here. set -euo pipefail +# ── Dotfiles (bare git repo, MacStudio branch) ──────────────────────────────── +# The dotfiles repo uses the "bare repo in $HOME" pattern. +# We clone a specific branch so we get the VS Code / Codespace-aware config +# (e.g. the P10k instant-prompt guard for $TERM_PROGRAM == "vscode"). +DOTFILES_REPO="https://github.com/Just-Insane/dotfiles.git" +DOTFILES_BRANCH="MacStudio" +DOTFILES_DIR="${HOME}/.cfg" + +if [ ! -d "${DOTFILES_DIR}" ]; then + git clone --bare --branch "${DOTFILES_BRANCH}" "${DOTFILES_REPO}" "${DOTFILES_DIR}" + + # Check out dotfiles to $HOME. Use --force to overwrite any stub files + # created by the devcontainer (e.g. a default .bashrc). + git --git-dir="${DOTFILES_DIR}" --work-tree="${HOME}" checkout --force "${DOTFILES_BRANCH}" + + # Don't show untracked files (the whole home dir) in status. + git --git-dir="${DOTFILES_DIR}" --work-tree="${HOME}" \ + config --local status.showUntrackedFiles no + + echo "✓ Dotfiles checked out from ${DOTFILES_BRANCH}" +else + # Already exists (e.g. Codespace resumed) — just pull latest. + git --git-dir="${DOTFILES_DIR}" --work-tree="${HOME}" \ + fetch origin "${DOTFILES_BRANCH}" && \ + git --git-dir="${DOTFILES_DIR}" --work-tree="${HOME}" \ + checkout --force "${DOTFILES_BRANCH}" + echo "✓ Dotfiles updated" +fi + # ── Git identity ────────────────────────────────────────────────────────────── # Populate from Codespace user secrets if they aren't already set by dotfiles. if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then From 2166eab5c585c6f1dcdbabf0af0d6a792508fe36 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 20:12:26 +0000 Subject: [PATCH 46/68] fix(codespace): suppress corepack download prompt, source nvm in onCreate --- .devcontainer/onCreate.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index bc2d2a967..e06e627d7 100644 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -4,7 +4,19 @@ # Everything here is cached in the prebuild snapshot. set -euo pipefail +# ── Ensure the node feature's PATH additions are active ────────────────────── +# The devcontainers node feature installs via nvm; source it so `node`/`pnpm` +# resolve correctly even in non-login, non-interactive shells. +export NVM_DIR="${NVM_DIR:-/usr/local/share/nvm}" +# shellcheck source=/dev/null +[ -s "${NVM_DIR}/nvm.sh" ] && source "${NVM_DIR}/nvm.sh" --no-use +# Activate the version pinned in .nvmrc / package.json engines. +nvm use 24 2>/dev/null || nvm use node + # ── pnpm ────────────────────────────────────────────────────────────────────── +# Suppress corepack's interactive download-confirmation prompt in CI/prebuild. +export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + # Enable corepack so the exact pnpm version from package.json#packageManager is used. corepack enable From 3f4ce4ed8c112824ad812ea9eeb8366480fd3b8d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 20:17:55 +0000 Subject: [PATCH 47/68] fix(codespace): chown pnpm store volume before writing --- .devcontainer/onCreate.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index e06e627d7..d9ac8e0c2 100644 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -13,6 +13,12 @@ export NVM_DIR="${NVM_DIR:-/usr/local/share/nvm}" # Activate the version pinned in .nvmrc / package.json engines. nvm use 24 2>/dev/null || nvm use node +# ── Fix named-volume ownership ──────────────────────────────────────────────── +# Docker mounts named volumes as root; fix ownership so the vscode user can write. +if [ -d "${PNPM_STORE_DIR:-}" ]; then + sudo chown -R "$(id -u):$(id -g)" "${PNPM_STORE_DIR}" +fi + # ── pnpm ────────────────────────────────────────────────────────────────────── # Suppress corepack's interactive download-confirmation prompt in CI/prebuild. export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 From d086af80422dbd94ef578c583c187e8ceb474d5b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 21:26:03 +0000 Subject: [PATCH 48/68] chore(devcontainer): add tmux, fix terminal font, add GitHub MCP server --- .devcontainer/devcontainer.json | 2 +- .devcontainer/onCreate.sh | 6 +++--- .vscode/mcp.json | 10 ++++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 .vscode/mcp.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f5223c3cb..e3fe567e9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -53,7 +53,7 @@ "editor.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - "terminal.integrated.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", + "terminal.integrated.fontFamily": "'MesloLGS NF', 'Fira Code', Menlo, monospace", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index d9ac8e0c2..2f2943fa9 100644 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -37,9 +37,9 @@ pnpm install # Install these during prebuild so the first Codespace start is fast. # The dotfiles checkout in postCreate.sh will provide .zshrc / .p10k.zsh. -# Install zsh if not already present (base:bookworm ships it, but be safe). -if ! command -v zsh &>/dev/null; then - sudo apt-get update -qq && sudo apt-get install -y -qq zsh +# Install zsh and tmux if not already present (base:bookworm ships zsh, but be safe). +if ! command -v zsh &>/dev/null || ! command -v tmux &>/dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq zsh tmux fi # Install Oh My Zsh non-interactively (KEEP_ZSHRC=yes preserves any existing .zshrc). diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000..b2bc0a4e8 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,10 @@ +{ + // GitHub MCP server — uses existing Copilot auth, no token prompt needed. + // Works in browser-based Codespaces (no vscode:// redirect required). + "servers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/" + } + } +} From 7068879f954b6001a2178045ce23afd2c9f8c13f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 22:14:04 +0000 Subject: [PATCH 49/68] fix(devcontainer): use browser-safe font and compatible p10k glyphs for iPad --- .devcontainer/devcontainer.json | 4 +++- .devcontainer/postCreate.sh | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e3fe567e9..4f00813c1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -53,7 +53,9 @@ "editor.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - "terminal.integrated.fontFamily": "'MesloLGS NF', 'Fira Code', Menlo, monospace", + // MesloLGS NF is a local system font — unavailable in the browser. + // Fira Code is loaded as a web font via the tonsky.font-fira-code extension. + "terminal.integrated.fontFamily": "'Fira Code', Menlo, monospace", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 8d9a404f7..5df377989 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -32,6 +32,16 @@ else echo "✓ Dotfiles updated" fi +# ── Powerlevel10k — browser-compatible glyph mode ──────────────────────────── +# MesloLGS NF / Nerd Font glyphs are unavailable in browser-based Codespaces. +# Patch .p10k.zsh to use the 'compatible' Unicode symbol set instead, which +# renders correctly with any modern monospace font (e.g. Fira Code via extension). +if [ -f "${HOME}/.p10k.zsh" ]; then + sed -i "s/POWERLEVEL9K_MODE='nerdfont-v3'/POWERLEVEL9K_MODE='compatible'/" \ + "${HOME}/.p10k.zsh" + echo "✓ p10k mode set to compatible" +fi + # ── Git identity ────────────────────────────────────────────────────────────── # Populate from Codespace user secrets if they aren't already set by dotfiles. if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then From 217372f619a0c81c51907c34c6f64bcc266e5525 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 22:59:15 +0000 Subject: [PATCH 50/68] fix(devcontainer): use Menlo as terminal font for iOS compatibility --- .devcontainer/devcontainer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4f00813c1..1390755db 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -54,8 +54,10 @@ "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, // MesloLGS NF is a local system font — unavailable in the browser. - // Fira Code is loaded as a web font via the tonsky.font-fira-code extension. - "terminal.integrated.fontFamily": "'Fira Code', Menlo, monospace", + // Fira Code (loaded via the tonsky extension) works for the editor renderer but + // not for the terminal canvas/DOM renderer on iOS — it doesn't arrive in time. + // Menlo is a native iOS/macOS system font and is always immediately available. + "terminal.integrated.fontFamily": "Menlo, 'Courier New', monospace", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", From 48ff73fcad3c5509246103f9278dff236877bc51 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 14:18:28 +0000 Subject: [PATCH 51/68] update devcontainer settings --- .devcontainer/devcontainer.json | 14 +++----------- .devcontainer/postCreate.sh | 2 +- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1390755db..21a144e1a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,7 +22,7 @@ // postCreate.sh will wire up git automatically if set. // // GIT_USER_NAME — e.g. "Evie" - // GIT_USER_EMAIL — e.g. "you@example.com" + // GIT_USER_EMAIL — e.g. "evie@gauthier.id" // ─────────────────────────────────────────────────────────────────────────── "remoteEnv": { @@ -50,14 +50,8 @@ "editor.scrollBeyondLastLine": false, // Larger default fonts for retina/HiDPI iPad displays. "editor.fontSize": 14, - "editor.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", - "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - // MesloLGS NF is a local system font — unavailable in the browser. - // Fira Code (loaded via the tonsky extension) works for the editor renderer but - // not for the terminal canvas/DOM renderer on iOS — it doesn't arrive in time. - // Menlo is a native iOS/macOS system font and is always immediately available. - "terminal.integrated.fontFamily": "Menlo, 'Courier New', monospace", + // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", @@ -94,9 +88,7 @@ // Named volume keeps the pnpm content-addressable store across rebuilds. // Combined with the PNPM_STORE_DIR env var above so postCreate can also // point pnpm at the same path. - "mounts": [ - "source=sable-pnpm-store,target=/home/vscode/.pnpm-store,type=volume" - ], + "mounts": ["source=sable-pnpm-store,target=/home/vscode/.pnpm-store,type=volume"], "postCreateCommand": "bash .devcontainer/postCreate.sh", "onCreateCommand": "bash .devcontainer/onCreate.sh", diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 5df377989..2e88c4e41 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -84,7 +84,7 @@ if [ -n "${GIT_SIGNING_KEY:-}" ]; then # Allow this key when verifying signatures locally. ALLOWED_SIGNERS="${SSH_DIR}/allowed_signers" - EMAIL="$(git config --global user.email 2>/dev/null || echo "you@example.com")" + EMAIL="$(git config --global user.email 2>/dev/null || echo "evie@gauthier.id")" echo "${EMAIL} $(cat "${KEY_FILE}.pub")" > "${ALLOWED_SIGNERS}" git config --global gpg.ssh.allowedSignersFile "${ALLOWED_SIGNERS}" From 158d2fd9561cd9e877385888f989e9ca0697d22f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 14:48:36 +0000 Subject: [PATCH 52/68] fix(devcontainer): restore missing fontFamily settings --- .devcontainer/devcontainer.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 21a144e1a..e8adea620 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -49,8 +49,13 @@ "editor.minimap.enabled": false, "editor.scrollBeyondLastLine": false, // Larger default fonts for retina/HiDPI iPad displays. + // Fira Code is loaded as a web font by the tonsky.font-fira-code extension, + // making it available in the browser terminal (Safari on iPad included). "editor.fontSize": 14, + "editor.fontFamily": "'Fira Code', Menlo, monospace", + "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, + "terminal.integrated.fontFamily": "'Fira Code', Menlo, monospace", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", From 22d0318d3daf882515fea09e682713ba205aa1f7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 14:50:09 +0000 Subject: [PATCH 53/68] Update fontfamily --- .devcontainer/devcontainer.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e8adea620..eb77e0164 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -52,10 +52,11 @@ // Fira Code is loaded as a web font by the tonsky.font-fira-code extension, // making it available in the browser terminal (Safari on iPad included). "editor.fontSize": 14, - "editor.fontFamily": "'Fira Code', Menlo, monospace", + "editor.fontFamily": "'MesloLGS NF', 'Fira Code', Meslo, Monaco, 'Courier New', monospace", "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - "terminal.integrated.fontFamily": "'Fira Code', Menlo, monospace", + "terminal.integrated.fontFamily": "'MesloLGS NF', 'Fira Code', Meslo, Monaco, 'Courier New', monospace", + "terminal.integrated.fontLigatures.enabled": true, // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", From 9a95e8bb3a375a9f1a7afaddb528a86c97bfee37 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 14:55:58 +0000 Subject: [PATCH 54/68] chore(devcontainer): sync extensions list with installed extensions --- .devcontainer/devcontainer.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index eb77e0164..d1e4039f2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -73,12 +73,37 @@ "github.copilot.chat.followUps": "always" }, "extensions": [ + // ── Copilot ─────────────────────────────────────────────────────────── "GitHub.copilot", "GitHub.copilot-chat", + "GitHub.vscode-pull-request-github", + // ── Font (web font — required for terminal in browser/iPad) ─────────── "tonsky.font-fira-code", + // ── Theme ───────────────────────────────────────────────────────────── + "GitHub.github-vscode-theme", + // ── Formatting & linting ────────────────────────────────────────────── "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", - "vitest.explorer" + "streetsidesoftware.code-spell-checker", + "davidanson.vscode-markdownlint", + // ── Testing ─────────────────────────────────────────────────────────── + "vitest.explorer", + // ── TypeScript / React ──────────────────────────────────────────────── + "bradlc.vscode-tailwindcss", + "styled-components.vscode-styled-components", + "dsznajder.es7-react-js-snippets", + "formulahendry.auto-rename-tag", + "wix.vscode-import-cost", + // ── Utilities ───────────────────────────────────────────────────────── + "christian-kohler.path-intellisense", + "usernamehw.errorlens", + "gruntfuggly.todo-tree", + "wayou.vscode-todo-highlight", + "webpro.vscode-knip", + "lokalise.i18n-ally", + // ── Infrastructure ──────────────────────────────────────────────────── + "hashicorp.terraform", + "zamerick.vscode-caddyfile-syntax" ] } }, From 306f2b5e79bd4bee82a195e902bea79cccd74ae9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 15:39:38 +0000 Subject: [PATCH 55/68] Update container config --- .devcontainer/devcontainer.json | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d1e4039f2..da8fcc009 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -49,14 +49,19 @@ "editor.minimap.enabled": false, "editor.scrollBeyondLastLine": false, // Larger default fonts for retina/HiDPI iPad displays. - // Fira Code is loaded as a web font by the tonsky.font-fira-code extension, - // making it available in the browser terminal (Safari on iPad included). + // Fira Code is loaded as a web font by the tonsky.font-fira-code extension. + // This works for the Monaco *editor* (HTML/CSS rendered), but xterm.js uses + // canvas drawing — it does NOT reliably inherit CSS @font-face on iOS Safari. + // MesloLGS NF / Monaco / Meslo are not iOS system fonts either. + // → Editor: Fira Code via extension is fine. + // → Terminal: use Menlo only (ships with iOS since iOS 7, always available). "editor.fontSize": 14, - "editor.fontFamily": "'MesloLGS NF', 'Fira Code', Meslo, Monaco, 'Courier New', monospace", + "editor.fontFamily": "'MesloLGS NF', 'Fira Code', Menlo, 'Courier New', monospace", "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - "terminal.integrated.fontFamily": "'MesloLGS NF', 'Fira Code', Meslo, Monaco, 'Courier New', monospace", - "terminal.integrated.fontLigatures.enabled": true, + "terminal.integrated.fontFamily": "Menlo, 'Courier New', monospace", + "terminal.integrated.fontLigatures.enabled": false, + "terminal.integrated.gpuAcceleration": "off", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", From d140fa4ffc9265936f22530fca66611e5e523469 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 16:38:55 +0000 Subject: [PATCH 56/68] fix(devcontainer): load signing key into ssh-agent in postCreate --- .devcontainer/postCreate.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 2e88c4e41..52e37fb11 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -88,6 +88,10 @@ if [ -n "${GIT_SIGNING_KEY:-}" ]; then echo "${EMAIL} $(cat "${KEY_FILE}.pub")" > "${ALLOWED_SIGNERS}" git config --global gpg.ssh.allowedSignersFile "${ALLOWED_SIGNERS}" + # Load the key into the ssh-agent so it's available for signing and SSH auth. + eval "$(ssh-agent -s)" &>/dev/null || true + ssh-add "${KEY_FILE}" + echo "✓ Git SSH commit signing configured (${KEY_FILE}.pub)" fi From cf38874c207ffa6273109b9a42dcfa5e72047600 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 16:39:54 +0000 Subject: [PATCH 57/68] feat(devcontainer): add SSH_AUTH_KEY secret support for server access --- .devcontainer/devcontainer.json | 4 ++++ .devcontainer/postCreate.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index da8fcc009..c7376421c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,6 +21,10 @@ // "signing key": github.com/settings/keys // postCreate.sh will wire up git automatically if set. // + // SSH_AUTH_KEY — passphrase-free SSH private key (ed25519 recommended). + // Add the matching public key to ~/.ssh/authorized_keys on + // any server you want to SSH into from the Codespace. + // // GIT_USER_NAME — e.g. "Evie" // GIT_USER_EMAIL — e.g. "evie@gauthier.id" // ─────────────────────────────────────────────────────────────────────────── diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 52e37fb11..08be0b1ee 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -95,4 +95,32 @@ if [ -n "${GIT_SIGNING_KEY:-}" ]; then echo "✓ Git SSH commit signing configured (${KEY_FILE}.pub)" fi +# ── SSH auth key ────────────────────────────────────────────────────────────── +# Requires a Codespace user secret named SSH_AUTH_KEY containing a +# passphrase-free SSH private key (ed25519 recommended). +# +# To set up: +# 1. Generate a key: ssh-keygen -t ed25519 -C "codespace auth" -N "" -f ~/.ssh/id_ed25519 +# 2. Copy the private key into a GitHub Codespace secret called SSH_AUTH_KEY: +# github.com/settings/codespaces > Secrets > New secret +# 3. Add the *public* key to ~/.ssh/authorized_keys on your server. +# ---------------------------------------------------------------------------- +if [ -n "${SSH_AUTH_KEY:-}" ]; then + SSH_DIR="${HOME}/.ssh" + mkdir -p "${SSH_DIR}" + chmod 700 "${SSH_DIR}" + + AUTH_KEY_FILE="${SSH_DIR}/id_ed25519" + printf '%s\n' "${SSH_AUTH_KEY}" > "${AUTH_KEY_FILE}" + chmod 600 "${AUTH_KEY_FILE}" + + ssh-keygen -y -f "${AUTH_KEY_FILE}" > "${AUTH_KEY_FILE}.pub" + chmod 644 "${AUTH_KEY_FILE}.pub" + + eval "$(ssh-agent -s)" &>/dev/null || true + ssh-add "${AUTH_KEY_FILE}" + + echo "✓ SSH auth key loaded (${AUTH_KEY_FILE}.pub)" +fi + echo "✓ postCreate complete" From 9d2f8441952a7a6e97610ab86ac271bda6d5ccde Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 17:00:56 +0000 Subject: [PATCH 58/68] fix(devcontainer): disable extension MCP auto-discovery, fix p10k sed pattern --- .devcontainer/devcontainer.json | 7 ++++++- .devcontainer/postCreate.sh | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c7376421c..66fddc62d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -79,7 +79,12 @@ // ── Copilot Chat ────────────────────────────────────────────────────── // Always show follow-ups and keep chat history accessible. - "github.copilot.chat.followUps": "always" + "github.copilot.chat.followUps": "always", + // Disable auto-discovery of extension-provided MCP servers (e.g. the + // io.github.github/github-mcp-server registered by vscode-pull-request-github). + // Our explicit HTTP server in .vscode/mcp.json is unaffected and handles all + // GitHub MCP calls without requiring a token prompt. + "chat.mcp.discovery.enabled": false }, "extensions": [ // ── Copilot ─────────────────────────────────────────────────────────── diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 08be0b1ee..053c91c23 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -36,10 +36,13 @@ fi # MesloLGS NF / Nerd Font glyphs are unavailable in browser-based Codespaces. # Patch .p10k.zsh to use the 'compatible' Unicode symbol set instead, which # renders correctly with any modern monospace font (e.g. Fira Code via extension). +# The POWERLEVEL9K_MODE line has no quotes: POWERLEVEL9K_MODE=nerdfont-complete if [ -f "${HOME}/.p10k.zsh" ]; then - sed -i "s/POWERLEVEL9K_MODE='nerdfont-v3'/POWERLEVEL9K_MODE='compatible'/" \ + sed -i "s/POWERLEVEL9K_MODE=.*/POWERLEVEL9K_MODE=compatible/" \ "${HOME}/.p10k.zsh" echo "✓ p10k mode set to compatible" +else + echo "⚠ ~/.p10k.zsh not found — skipping p10k patch (add it to your dotfiles repo)" fi # ── Git identity ────────────────────────────────────────────────────────────── From 73c5ce8ecebf18c2b6acf8bec5aae3184391ccf0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 18:27:51 +0000 Subject: [PATCH 59/68] fix(devcontainer): enable shell integration for Copilot Chat terminal --- .devcontainer/devcontainer.json | 11 +++++++++++ .devcontainer/postCreate.sh | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 66fddc62d..f97cbc6fc 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -68,7 +68,18 @@ "terminal.integrated.gpuAcceleration": "off", // Use zsh (installed in onCreate) as the default terminal shell. + // Explicit profile with -l (login shell) ensures nvm / PATH additions + // from the devcontainer node feature are loaded inside the terminal. "terminal.integrated.defaultProfile.linux": "zsh", + "terminal.integrated.profiles.linux": { + "zsh": { "path": "/bin/zsh", "args": ["-l"] } + }, + + // Shell integration MUST be enabled for Copilot Chat to run terminal + // commands. We set it explicitly because Powerlevel10k instant prompt + // can fire before VS Code injects its integration script and suppress + // the markers — postCreate.sh patches .zshrc to guard against this. + "terminal.integrated.shellIntegration.enabled": true, // ── Git signing ─────────────────────────────────────────────────────── // postCreate.sh configures gpg.format and user.signingkey if diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 053c91c23..be95325ec 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -45,6 +45,30 @@ else echo "⚠ ~/.p10k.zsh not found — skipping p10k patch (add it to your dotfiles repo)" fi +# ── Powerlevel10k — disable instant prompt in VS Code terminal ──────────────── +# Instant prompt outputs to the terminal before VS Code injects its shell +# integration script. This breaks the integration markers that Copilot Chat +# relies on to run commands. We prepend a one-liner to .zshrc that sets +# POWERLEVEL9K_INSTANT_PROMPT=off whenever $TERM_PROGRAM is "vscode". +# The check is idempotent — safe to run on Codespace resume. +if [ -f "${HOME}/.zshrc" ]; then + if ! grep -q 'POWERLEVEL9K_INSTANT_PROMPT=off' "${HOME}/.zshrc"; then + tmp=$(mktemp) + { + printf '# Disable P10k instant prompt in VS Code — it fires before shell\n' + printf '# integration is injected, which breaks Copilot Chat terminal access.\n' + printf '[[ "$TERM_PROGRAM" == "vscode" ]] && typeset -g POWERLEVEL9K_INSTANT_PROMPT=off\n\n' + cat "${HOME}/.zshrc" + } > "$tmp" + mv "$tmp" "${HOME}/.zshrc" + echo "✓ P10k instant prompt disabled for VS Code terminal" + else + echo "✓ P10k instant prompt VS Code guard already present" + fi +else + echo "⚠ ~/.zshrc not found — skipping instant-prompt patch (dotfiles not checked out?)" +fi + # ── Git identity ────────────────────────────────────────────────────────────── # Populate from Codespace user secrets if they aren't already set by dotfiles. if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then From 1b0c347ccc8faf75249c2b400ae4a275afb76df8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 8 Apr 2026 18:24:20 +0000 Subject: [PATCH 60/68] chore(devcontainer): switch dotfiles branch to codespaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux/Codespaces-clean branch — removes macOS NVM lazy-loader, Homebrew paths, macOS-only OMZ plugins, and hardcoded macOS gitconfig paths. --- .devcontainer/postCreate.sh | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index be95325ec..58dec301c 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -8,7 +8,7 @@ set -euo pipefail # We clone a specific branch so we get the VS Code / Codespace-aware config # (e.g. the P10k instant-prompt guard for $TERM_PROGRAM == "vscode"). DOTFILES_REPO="https://github.com/Just-Insane/dotfiles.git" -DOTFILES_BRANCH="MacStudio" +DOTFILES_BRANCH="codespaces" DOTFILES_DIR="${HOME}/.cfg" if [ ! -d "${DOTFILES_DIR}" ]; then @@ -45,25 +45,29 @@ else echo "⚠ ~/.p10k.zsh not found — skipping p10k patch (add it to your dotfiles repo)" fi -# ── Powerlevel10k — disable instant prompt in VS Code terminal ──────────────── +# ── Powerlevel10k — disable instant prompt in Codespace terminal ────────────── # Instant prompt outputs to the terminal before VS Code injects its shell # integration script. This breaks the integration markers that Copilot Chat -# relies on to run commands. We prepend a one-liner to .zshrc that sets -# POWERLEVEL9K_INSTANT_PROMPT=off whenever $TERM_PROGRAM is "vscode". +# relies on to run commands. +# We unconditionally disable it here because: +# - In a Codespace, VS Code shell integration is always needed for Copilot Chat. +# - $TERM_PROGRAM is NOT reliably set to "vscode" in browser-based Codespaces +# (e.g. iPad / vscode.dev), so a conditional guard can silently fail. # The check is idempotent — safe to run on Codespace resume. if [ -f "${HOME}/.zshrc" ]; then if ! grep -q 'POWERLEVEL9K_INSTANT_PROMPT=off' "${HOME}/.zshrc"; then tmp=$(mktemp) { - printf '# Disable P10k instant prompt in VS Code — it fires before shell\n' - printf '# integration is injected, which breaks Copilot Chat terminal access.\n' - printf '[[ "$TERM_PROGRAM" == "vscode" ]] && typeset -g POWERLEVEL9K_INSTANT_PROMPT=off\n\n' + printf '# Disable P10k instant prompt — it fires before VS Code shell\n' + printf '# integration is injected, breaking Copilot Chat terminal access.\n' + printf '# Unconditional: $TERM_PROGRAM is not reliable in browser Codespaces.\n' + printf 'typeset -g POWERLEVEL9K_INSTANT_PROMPT=off\n\n' cat "${HOME}/.zshrc" } > "$tmp" mv "$tmp" "${HOME}/.zshrc" - echo "✓ P10k instant prompt disabled for VS Code terminal" + echo "✓ P10k instant prompt unconditionally disabled" else - echo "✓ P10k instant prompt VS Code guard already present" + echo "✓ P10k instant prompt already disabled" fi else echo "⚠ ~/.zshrc not found — skipping instant-prompt patch (dotfiles not checked out?)" From 9756a59cef6855b7deb9f10ddeaf951f5ceccaf9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:00:19 -0400 Subject: [PATCH 61/68] chore(prompts): add rebuild integration and review upstream PRs prompts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/prompts/rebuild integration.prompt.md | 12 ++++++++++++ .../review open PRs against `upstream`.prompt.md | 10 ++++++++++ 2 files changed, 22 insertions(+) create mode 100644 .github/prompts/rebuild integration.prompt.md create mode 100644 .github/prompts/review open PRs against `upstream`.prompt.md diff --git a/.github/prompts/rebuild integration.prompt.md b/.github/prompts/rebuild integration.prompt.md new file mode 100644 index 000000000..000673e52 --- /dev/null +++ b/.github/prompts/rebuild integration.prompt.md @@ -0,0 +1,12 @@ +--- +name: rebuild integration +description: When asked to rebuild integration, or if there are large numbers of changes to branches +--- + + + +Please rebuild the `integration` branch, by deleting `integration` and then creating a new `integration` branch from `dev`, after updating `dev` from `upstream/dev` (and push `dev` to `origin/dev`). This is needed because there are large numbers of changes to branches, and rebuilding the integration branch will help to ensure that it is up to date with the latest changes. + +Please prompt for which branches to include, and always include `personal/config`, as it is needed for the integration branch to work properly. If there are any other branches that need to be included, please prompt for those as well. + +We should also ensure that any necessary tests are run after rebuilding the integration branch, to verify that everything is working correctly. Please let me know if you have any questions or need any assistance with this process. \ No newline at end of file diff --git a/.github/prompts/review open PRs against `upstream`.prompt.md b/.github/prompts/review open PRs against `upstream`.prompt.md new file mode 100644 index 000000000..7c85531be --- /dev/null +++ b/.github/prompts/review open PRs against `upstream`.prompt.md @@ -0,0 +1,10 @@ +--- +name: review open PRs against `upstream` +description: When asked to review open PRs against `upstream` +--- + + + +Please look for all of my open/pending PRs against `upstream`, and review them for any issues, such as merge conflicts, failing checks, comments, or outdated code. If you find any problems, please provide feedback on how to resolve them. And/or implement the necessary changes to get the PRs ready for merging. + +Once done, please provide a summary of the status of each PR, including any actions taken or needed to get them merged. \ No newline at end of file From 35afbdebe69f24420796f9e0ae75291793c5d6ec Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 17:40:05 -0400 Subject: [PATCH 62/68] fix(tests): guard localStorage access against Node.js 22 built-in stub - debugLogger: wrap constructor localStorage.getItem in try/catch - settings: wrap getSettings/setSettings in try/catch - test/setup.ts: install in-memory localStorage polyfill before jsdom initialises so module-level singleton access resolves correctly All 60 test files (570 tests) now pass on Node.js 22. --- src/app/state/settings.ts | 41 +++++++++++++++++++++++++----------- src/app/utils/debugLogger.ts | 11 ++++++++-- src/test/setup.ts | 28 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index cd580e9e7..5edd32f06 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -350,13 +350,17 @@ export function mergePersistedSettings( const base = { ...defaultSettings, ...fileDefaults }; if (rawLocalStorage === null) return base; - const parsed = JSON.parse(rawLocalStorage) as Record; - migrateParsedLocalStorage(parsed); - - return { - ...base, - ...(parsed as unknown as Settings), - }; + try { + const parsed = JSON.parse(rawLocalStorage) as Record; + migrateParsedLocalStorage(parsed); + + return { + ...base, + ...(parsed as unknown as Settings), + }; + } catch { + return base; + } } const MESSAGE_SPACING_VALUES = new Set(['0', '100', '200', '300', '400', '500']); @@ -531,15 +535,28 @@ export const baseSettings = atom(cloneDefaultSettings()); export function bootstrapSettingsStore(store: Store, rawSettingsDefaults: unknown): void { const sanitized = sanitizeSettingsDefaults(rawSettingsDefaults); runtimeSettingsDefaults = sanitized; - const merged = mergePersistedSettings(localStorage.getItem(STORAGE_KEY), sanitized); - store.set(baseSettings, merged); + try { + const merged = mergePersistedSettings(localStorage.getItem(STORAGE_KEY), sanitized); + store.set(baseSettings, merged); + } catch { + store.set(baseSettings, { ...defaultSettings, ...sanitized }); + } } -export const getSettings = (): Settings => - mergePersistedSettings(localStorage.getItem(STORAGE_KEY), runtimeSettingsDefaults); +export const getSettings = (): Settings => { + try { + return mergePersistedSettings(localStorage.getItem(STORAGE_KEY), runtimeSettingsDefaults); + } catch { + return { ...defaultSettings, ...runtimeSettingsDefaults }; + } +}; export const setSettings = (settings: Settings) => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch { + // localStorage may be unavailable (e.g., Node.js 22 test environment) + } }; export const settingsAtom = atom( diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index 88f787328..e727fa5ab 100644 --- a/src/app/utils/debugLogger.ts +++ b/src/app/utils/debugLogger.ts @@ -47,8 +47,15 @@ class DebugLoggerService { private sentryStats = { errors: 0, warnings: 0 }; constructor() { - // Check if debug logging is enabled from localStorage - this.enabled = localStorage.getItem('sable_internal_debug') === '1'; + // Check if debug logging is enabled from localStorage. + // Guarded with try/catch because this module is instantiated as a singleton + // at import time, which in Node.js 22+ can run before a jsdom environment + // is ready (Node has a built-in but non-functional localStorage stub). + try { + this.enabled = localStorage.getItem('sable_internal_debug') === '1'; + } catch { + this.enabled = false; + } // Load disabled breadcrumb categories try { const stored = localStorage.getItem(BREADCRUMB_DISABLED_KEY); diff --git a/src/test/setup.ts b/src/test/setup.ts index 7b0828bfa..28c93a12c 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,29 @@ import '@testing-library/jest-dom'; + +// Node.js 22+ ships a built-in `localStorage` stub that throws for getItem/setItem +// unless --localstorage-file is supplied at startup. jsdom relies on being able to +// define window.localStorage, but Node's version can prevent that. We install an +// in-memory implementation unconditionally so every test environment starts with a +// working, isolated localStorage regardless of runtime version. +const _store = new Map(); +const _localStorage = { + getItem: (key: string): string | null => _store.get(key) ?? null, + setItem: (key: string, value: string): void => { + _store.set(key, value); + }, + removeItem: (key: string): void => { + _store.delete(key); + }, + clear: (): void => { + _store.clear(); + }, + get length(): number { + return _store.size; + }, + key: (index: number): string | null => [..._store.keys()][index] ?? null, +}; +Object.defineProperty(globalThis, 'localStorage', { + value: _localStorage, + writable: true, + configurable: true, +}); From d84299aa541cf611f2f9ffd17dd711c41311343f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 17:54:53 -0400 Subject: [PATCH 63/68] fix(roomToUnread): prevent infinite loop in UnreadNotifications handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoomEvent.UnreadNotifications is emitted BY room.fixupNotifications(). The handleUnreadNotifications listener was calling getUnreadInfo with applyFixup: true, which calls room.fixupNotifications() again, producing an infinite loop: fixupNotifications → setUnreadNotificationCount → emit → handleUnreadNotifications → getUnreadInfo → fixupNotifications → … Fix: pass applyFixup: false inside the UnreadNotifications handler. The fixup has already run (it's what triggered the event); reading the updated counts directly avoids re-entering the fixup path. --- src/app/state/room/roomToUnread.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index 24ded9f80..3e70f2870 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -311,8 +311,11 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo if (room.isSpaceRoom()) return; if (room.getMyMembership() !== (KnownMembership.Join as string)) return; + // Do NOT pass applyFixup here: RoomEvent.UnreadNotifications is itself fired *by* + // fixupNotifications(), so calling room.fixupNotifications() again from within this + // handler causes infinite recursion (fixupNotifications → setUnread → + // setUnreadNotificationCount → emit → this handler → fixupNotifications → …). const unreadInfo = getUnreadInfo(room, { - // Counts are already updated before this event would recurse if true applyFixup: false, mDirects, }); From e84b0a76ee502cafbd0dc221f46d0e2c8913854f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 19:01:15 -0400 Subject: [PATCH 64/68] fix(bookmarks): migrate to MSC4438 per-event format, add inbox view - useBookmarks: reads org.matrix.msc4438.bookmark. events + index - toggleBookmark: writes/removes individual events + updates index - BookmarksList: shared component for panel and inbox page - Add /inbox/bookmarks/ route with full-page bookmarks view --- src/app/features/bookmarks/BookmarksList.tsx | 137 ++++++++++++++++++ src/app/features/bookmarks/BookmarksPanel.tsx | 4 +- src/app/hooks/router/useInbox.ts | 17 ++- src/app/hooks/useBookmarks.ts | 82 ++++++++--- src/app/pages/Router.tsx | 4 +- src/app/pages/client/inbox/Bookmarks.tsx | 28 ++++ src/app/pages/client/inbox/Inbox.tsx | 29 +++- src/app/pages/client/inbox/index.ts | 1 + src/app/pages/pathUtils.ts | 2 + src/app/pages/paths.ts | 2 + src/types/matrix-sdk-events.d.ts | 5 +- src/types/matrix/accountData.ts | 4 +- 12 files changed, 290 insertions(+), 25 deletions(-) create mode 100644 src/app/features/bookmarks/BookmarksList.tsx create mode 100644 src/app/pages/client/inbox/Bookmarks.tsx diff --git a/src/app/features/bookmarks/BookmarksList.tsx b/src/app/features/bookmarks/BookmarksList.tsx new file mode 100644 index 000000000..0e18279ef --- /dev/null +++ b/src/app/features/bookmarks/BookmarksList.tsx @@ -0,0 +1,137 @@ +import { Avatar, Box, Icon, IconButton, Icons, Scroll, Text, config } from 'folds'; +import { useBookmarks, toggleBookmark } from '$hooks/useBookmarks'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { useGetRoom, useAllJoinedRoomsSet } from '$hooks/useGetRoom'; +import { getRoomAvatarUrl } from '$utils/room'; +import { nameInitials } from '$utils/common'; +import { RoomAvatar } from '$components/room-avatar'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; + +type BookmarksListProps = { + onNavigate?: () => void; +}; + +export function BookmarksList({ onNavigate }: BookmarksListProps) { + const mx = useMatrixClient(); + const bookmarks = useBookmarks(); + const { navigateRoom } = useRoomNavigate(); + const useAuthentication = useMediaAuthentication(); + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + + const handleOpen = (roomId: string, eventId: string) => { + navigateRoom(roomId, eventId); + onNavigate?.(); + }; + + const handleRemove = (roomId: string, eventId: string) => { + toggleBookmark(mx, roomId, eventId, bookmarks).catch(() => {}); + }; + + if (bookmarks.length === 0) { + return ( + + + + No bookmarks yet + + + Bookmark messages from the message menu to save them here. + + + ); + } + + return ( + + + {bookmarks.map((bookmark) => { + const room = getRoom(bookmark.room_id); + const event = room + ?.getTimelineForEvent(bookmark.event_id) + ?.getEvents() + .find((e) => e.getId() === bookmark.event_id); + + const senderDisplayName = event + ? (room?.getMember(event.getSender() ?? '')?.name ?? event.getSender() ?? 'Unknown') + : 'Unknown'; + const body = (event?.getContent() as Record | undefined)?.body as + | string + | undefined; + const preview = body + ? body.length > 100 + ? `${body.slice(0, 100)}…` + : body + : 'Encrypted or unknown message'; + + return ( + handleOpen(bookmark.room_id, bookmark.event_id)} + as="button" + > + + {room ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + + + + {room?.name ?? bookmark.room_id} + + + {senderDisplayName} + + + + {preview} + + + { + e.stopPropagation(); + handleRemove(bookmark.room_id, bookmark.event_id); + }} + aria-label="Remove bookmark" + > + + + + ); + })} + + + ); +} diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx index e300e6762..d9b9c0462 100644 --- a/src/app/features/bookmarks/BookmarksPanel.tsx +++ b/src/app/features/bookmarks/BookmarksPanel.tsx @@ -22,12 +22,14 @@ import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { useGetRoom } from '$hooks/useGetRoom'; import { allRoomsAtom } from '$state/room-list/roomList'; import { useAllJoinedRoomsSet } from '$hooks/useGetRoom'; -import { getRoomAvatarUrl, getDirectRoomAvatarUrl } from '$utils/room'; +import { getRoomAvatarUrl } from '$utils/room'; import { nameInitials } from '$utils/common'; import { RoomAvatar } from '$components/room-avatar'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { stopPropagation } from '$utils/keyboard'; +export { BookmarksList } from './BookmarksList'; + type BookmarksPanelProps = { requestClose: () => void; }; diff --git a/src/app/hooks/router/useInbox.ts b/src/app/hooks/router/useInbox.ts index 639e16dd4..c19c0cc4b 100644 --- a/src/app/hooks/router/useInbox.ts +++ b/src/app/hooks/router/useInbox.ts @@ -1,5 +1,10 @@ import { useMatch } from 'react-router-dom'; -import { getInboxInvitesPath, getInboxNotificationsPath, getInboxPath } from '$pages/pathUtils'; +import { + getInboxBookmarksPath, + getInboxInvitesPath, + getInboxNotificationsPath, + getInboxPath, +} from '$pages/pathUtils'; export const useInboxSelected = (): boolean => { const match = useMatch({ @@ -30,3 +35,13 @@ export const useInboxInvitesSelected = (): boolean => { return !!match; }; + +export const useInboxBookmarksSelected = (): boolean => { + const match = useMatch({ + path: getInboxBookmarksPath(), + caseSensitive: true, + end: false, + }); + + return !!match; +}; diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts index bda021e80..ea1d0937f 100644 --- a/src/app/hooks/useBookmarks.ts +++ b/src/app/hooks/useBookmarks.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk'; import { ClientEvent } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -7,40 +7,78 @@ import { CustomAccountDataEvent } from '$types/matrix/accountData'; export type BookmarkEntry = { event_id: string; room_id: string; + /** MSC4438 bookmark key suffix, e.g. "bmk_a1b2c3d4" */ + id: string; }; -export type BookmarksContent = { - bookmarks: BookmarkEntry[]; -}; +// --------------------------------------------------------------------------- +// MSC4438 helpers +// --------------------------------------------------------------------------- + +const BOOKMARK_PREFIX = CustomAccountDataEvent.MSC4438BookmarkPrefix; // 'org.matrix.msc4438.bookmark.' +const INDEX_KEY = CustomAccountDataEvent.MSC4438BookmarksIndex; // 'org.matrix.msc4438.bookmarks.index' -function readBookmarks(mx: MatrixClient): BookmarkEntry[] { - const event = mx.getAccountData(CustomAccountDataEvent.SableBookmarks); - if (!event) return []; - const content = event.getContent(); +function generateBookmarkId(): string { + // 8 random hex chars, prefixed with "bmk_" + const bytes = new Uint8Array(4); + crypto.getRandomValues(bytes); + return `bmk_${Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('')}`; +} + +function getIndexIds(mx: MatrixClient): string[] { + const ev = mx.getAccountData(INDEX_KEY); + if (!ev) return []; + const content = ev.getContent<{ bookmarks?: string[] }>(); return Array.isArray(content.bookmarks) ? content.bookmarks : []; } +export function readBookmarks(mx: MatrixClient): BookmarkEntry[] { + const ids = getIndexIds(mx); + const entries: BookmarkEntry[] = []; + for (const id of ids) { + const ev = mx.getAccountData(`${BOOKMARK_PREFIX}${id}`); + if (!ev) continue; + const c = ev.getContent<{ room_id?: string; event_id?: string }>(); + if (c.room_id && c.event_id) { + entries.push({ id, room_id: c.room_id, event_id: c.event_id }); + } + } + return entries; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + export function useBookmarks(): BookmarkEntry[] { const mx = useMatrixClient(); const [bookmarks, setBookmarks] = useState(() => readBookmarks(mx)); + const refresh = useCallback(() => setBookmarks(readBookmarks(mx)), [mx]); + useEffect(() => { - setBookmarks(readBookmarks(mx)); + refresh(); const handler = (event: MatrixEvent) => { - if (event.getType() === (CustomAccountDataEvent.SableBookmarks as string)) { - const content = event.getContent(); - setBookmarks(Array.isArray(content.bookmarks) ? content.bookmarks : []); + const type = event.getType(); + if (type === INDEX_KEY || type.startsWith(BOOKMARK_PREFIX)) { + refresh(); } }; mx.on(ClientEvent.AccountData, handler); return () => { mx.off(ClientEvent.AccountData, handler); }; - }, [mx]); + }, [mx, refresh]); return bookmarks; } +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + export function isBookmarked(bookmarks: BookmarkEntry[], eventId: string): boolean { return bookmarks.some((b) => b.event_id === eventId); } @@ -51,9 +89,17 @@ export async function toggleBookmark( eventId: string, currentBookmarks: BookmarkEntry[] ): Promise { - const exists = isBookmarked(currentBookmarks, eventId); - const updated: BookmarkEntry[] = exists - ? currentBookmarks.filter((b) => b.event_id !== eventId) - : [...currentBookmarks, { event_id: eventId, room_id: roomId }]; - await mx.setAccountData(CustomAccountDataEvent.SableBookmarks, { bookmarks: updated }); + const existing = currentBookmarks.find((b) => b.event_id === eventId); + if (existing) { + // Remove: delete individual event, then update index + await mx.setAccountData(`${BOOKMARK_PREFIX}${existing.id}`, {}); + const newIds = currentBookmarks.filter((b) => b.event_id !== eventId).map((b) => b.id); + await mx.setAccountData(INDEX_KEY, { bookmarks: newIds }); + } else { + // Add: write individual event, then update index + const id = generateBookmarkId(); + await mx.setAccountData(`${BOOKMARK_PREFIX}${id}`, { room_id: roomId, event_id: eventId }); + const newIds = [...currentBookmarks.map((b) => b.id), id]; + await mx.setAccountData(INDEX_KEY, { bookmarks: newIds }); + } } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 490113e6f..4a16786ec 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -45,6 +45,7 @@ import { CREATE_PATH_SEGMENT, FEATURED_PATH_SEGMENT, INVITES_PATH_SEGMENT, + BOOKMARKS_PATH_SEGMENT, JOIN_PATH_SEGMENT, LOBBY_PATH_SEGMENT, NOTIFICATIONS_PATH_SEGMENT, @@ -70,7 +71,7 @@ import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; -import { Notifications, Inbox, Invites } from './client/inbox'; +import { Notifications, Inbox, Invites, Bookmarks } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { WelcomePage } from './client/WelcomePage'; import { SidebarNav } from './client/SidebarNav'; @@ -371,6 +372,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) )} } /> } /> + } /> } /> diff --git a/src/app/pages/client/inbox/Bookmarks.tsx b/src/app/pages/client/inbox/Bookmarks.tsx new file mode 100644 index 000000000..1ea404c4d --- /dev/null +++ b/src/app/pages/client/inbox/Bookmarks.tsx @@ -0,0 +1,28 @@ +import { Box, Icon, Icons, Text } from 'folds'; +import { Page, PageContent, PageHeader } from '$components/page'; +import { BookmarksList } from '$features/bookmarks/BookmarksList'; + +export function Bookmarks() { + return ( + + + + + + + Bookmarks + + + + + + + + + + + ); +} diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx index 661435513..fa7b901d4 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -1,8 +1,16 @@ import { Avatar, Box, Icon, Icons, Text } from 'folds'; import { useAtomValue } from 'jotai'; import { NavCategory, NavItem, NavItemContent, NavLink } from '$components/nav'; -import { getInboxInvitesPath, getInboxNotificationsPath } from '$pages/pathUtils'; -import { useInboxInvitesSelected, useInboxNotificationsSelected } from '$hooks/router/useInbox'; +import { + getInboxBookmarksPath, + getInboxInvitesPath, + getInboxNotificationsPath, +} from '$pages/pathUtils'; +import { + useInboxBookmarksSelected, + useInboxInvitesSelected, + useInboxNotificationsSelected, +} from '$hooks/router/useInbox'; import { UnreadBadge } from '$components/unread-badge'; import { allInvitesAtom } from '$state/room-list/inviteList'; import { useNavToActivePathMapper } from '$hooks/useNavToActivePathMapper'; @@ -42,6 +50,7 @@ function InvitesNavItem() { export function Inbox() { useNavToActivePathMapper('inbox'); const notificationsSelected = useInboxNotificationsSelected(); + const bookmarksSelected = useInboxBookmarksSelected(); return ( @@ -75,6 +84,22 @@ export function Inbox() { + + + + + + + + + + Bookmarks + + + + + + diff --git a/src/app/pages/client/inbox/index.ts b/src/app/pages/client/inbox/index.ts index c8036b471..dc02ccee6 100644 --- a/src/app/pages/client/inbox/index.ts +++ b/src/app/pages/client/inbox/index.ts @@ -1,3 +1,4 @@ export * from './Inbox'; export * from './Notifications'; export * from './Invites'; +export * from './Bookmarks'; diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 4a95f47fc..f942adf0a 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -18,6 +18,7 @@ import { LOGIN_PATH, INBOX_INVITES_PATH, INBOX_NOTIFICATIONS_PATH, + INBOX_BOOKMARKS_PATH, INBOX_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, @@ -158,6 +159,7 @@ export const getCreatePath = (): string => CREATE_PATH; export const getInboxPath = (): string => INBOX_PATH; export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH; export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH; +export const getInboxBookmarksPath = (): string => INBOX_BOOKMARKS_PATH; export const getSettingsPath = (section?: string, focus?: string): string => { const path = trimTrailingSlash(generatePath(SETTINGS_PATH, { section: section ?? null })); diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 1ac57b756..c36743930 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -82,12 +82,14 @@ export const CREATE_PATH = '/create'; export const NOTIFICATIONS_PATH_SEGMENT = 'notifications/'; export const INVITES_PATH_SEGMENT = 'invites/'; +export const BOOKMARKS_PATH_SEGMENT = 'bookmarks/'; export const INBOX_PATH = '/inbox/'; export type InboxNotificationsPathSearchParams = { only?: string; }; export const INBOX_NOTIFICATIONS_PATH = `/inbox/${NOTIFICATIONS_PATH_SEGMENT}`; export const INBOX_INVITES_PATH = `/inbox/${INVITES_PATH_SEGMENT}`; +export const INBOX_BOOKMARKS_PATH = `/inbox/${BOOKMARKS_PATH_SEGMENT}`; export const TO_PATH = '/to'; // Deep-link route used by push notification click-back URLs. diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts index 887e54fe5..91c33e8ed 100644 --- a/src/types/matrix-sdk-events.d.ts +++ b/src/types/matrix-sdk-events.d.ts @@ -50,6 +50,9 @@ declare module 'matrix-js-sdk/lib/@types/event' { [prefix.MATRIX_UNSTABLE_ACCOUNT_EMOTE_ROOMS_PROPERTY_NAME]: EmoteRoomsContent; [prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_NICKNAMES_PROPERTY_NAME]: Record; [prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_SETTINGS_PROPERTY_NAME]: Record; - 'moe.sable.app.bookmarks': { bookmarks: Array<{ event_id: string; room_id: string }> }; + // MSC4438 bookmark index — lists the per-bookmark event keys in order + 'org.matrix.msc4438.bookmarks.index': { bookmarks: string[] }; + // Individual MSC4438 bookmark events (dynamic keys handled by prefix convention) + [key: `org.matrix.msc4438.bookmark.${string}`]: { room_id: string; event_id: string }; } } diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index db2ed9838..074dc3c84 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -10,7 +10,9 @@ export const CustomAccountDataEvent = { SablePerProfileMessageProfiles: prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, SableSettings: prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_SETTINGS_PROPERTY_NAME, - SableBookmarks: 'moe.sable.app.bookmarks', + // MSC4438 bookmarks — individual bookmark events + an index + MSC4438BookmarkPrefix: 'org.matrix.msc4438.bookmark.', + MSC4438BookmarksIndex: 'org.matrix.msc4438.bookmarks.index', } as const; export type CustomAccountDataEvent = (typeof CustomAccountDataEvent)[keyof typeof CustomAccountDataEvent]; From ad89d8f06e8aeba8663dd721a19828374787753b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 19:27:56 -0400 Subject: [PATCH 65/68] fix(bookmarks): use bookmark_ids index key per MSC4438 - getIndexIds reads bookmark_ids instead of bookmarks - toggleBookmark writes bookmark_ids to index on add/remove - toggleBookmark marks deleted events with { deleted: true } instead of {} - readBookmarks skips events with deleted: true --- src/app/hooks/useBookmarks.ts | 25 ++++++++++++++++--------- src/types/matrix-sdk-events.d.ts | 2 +- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts index ea1d0937f..098dbd059 100644 --- a/src/app/hooks/useBookmarks.ts +++ b/src/app/hooks/useBookmarks.ts @@ -30,8 +30,8 @@ function generateBookmarkId(): string { function getIndexIds(mx: MatrixClient): string[] { const ev = mx.getAccountData(INDEX_KEY); if (!ev) return []; - const content = ev.getContent<{ bookmarks?: string[] }>(); - return Array.isArray(content.bookmarks) ? content.bookmarks : []; + const content = ev.getContent<{ bookmark_ids?: string[] }>(); + return Array.isArray(content.bookmark_ids) ? content.bookmark_ids : []; } export function readBookmarks(mx: MatrixClient): BookmarkEntry[] { @@ -40,8 +40,8 @@ export function readBookmarks(mx: MatrixClient): BookmarkEntry[] { for (const id of ids) { const ev = mx.getAccountData(`${BOOKMARK_PREFIX}${id}`); if (!ev) continue; - const c = ev.getContent<{ room_id?: string; event_id?: string }>(); - if (c.room_id && c.event_id) { + const c = ev.getContent<{ room_id?: string; event_id?: string; deleted?: boolean }>(); + if (!c.deleted && c.room_id && c.event_id) { entries.push({ id, room_id: c.room_id, event_id: c.event_id }); } } @@ -91,15 +91,22 @@ export async function toggleBookmark( ): Promise { const existing = currentBookmarks.find((b) => b.event_id === eventId); if (existing) { - // Remove: delete individual event, then update index - await mx.setAccountData(`${BOOKMARK_PREFIX}${existing.id}`, {}); + // Remove: update index first, then mark individual event deleted const newIds = currentBookmarks.filter((b) => b.event_id !== eventId).map((b) => b.id); - await mx.setAccountData(INDEX_KEY, { bookmarks: newIds }); + await mx.setAccountData(INDEX_KEY, { bookmark_ids: newIds }); + await mx.setAccountData(`${BOOKMARK_PREFIX}${existing.id}`, { + deleted: true, + bookmark_id: existing.id, + }); } else { // Add: write individual event, then update index const id = generateBookmarkId(); - await mx.setAccountData(`${BOOKMARK_PREFIX}${id}`, { room_id: roomId, event_id: eventId }); + await mx.setAccountData(`${BOOKMARK_PREFIX}${id}`, { + room_id: roomId, + event_id: eventId, + bookmark_id: id, + }); const newIds = [...currentBookmarks.map((b) => b.id), id]; - await mx.setAccountData(INDEX_KEY, { bookmarks: newIds }); + await mx.setAccountData(INDEX_KEY, { bookmark_ids: newIds }); } } diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts index 91c33e8ed..74f8c45cd 100644 --- a/src/types/matrix-sdk-events.d.ts +++ b/src/types/matrix-sdk-events.d.ts @@ -51,7 +51,7 @@ declare module 'matrix-js-sdk/lib/@types/event' { [prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_NICKNAMES_PROPERTY_NAME]: Record; [prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_SETTINGS_PROPERTY_NAME]: Record; // MSC4438 bookmark index — lists the per-bookmark event keys in order - 'org.matrix.msc4438.bookmarks.index': { bookmarks: string[] }; + 'org.matrix.msc4438.bookmarks.index': { bookmark_ids: string[] }; // Individual MSC4438 bookmark events (dynamic keys handled by prefix convention) [key: `org.matrix.msc4438.bookmark.${string}`]: { room_id: string; event_id: string }; } From 1d6cbcf78a5a66db92760dbfc0059168f6e2f599 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 19:30:16 -0400 Subject: [PATCH 66/68] fix(presence): show presence dot in account switcher, DM sidebar, and members drawer - AccountSwitcherTab: wrap SidebarAvatar in AvatarPresence with current user's dot - DirectDMsList: add AvatarPresence badge on 1:1 DM icons using the DM user's presence - MembersDrawer: replace lastActiveTs !== 0 guard with presence !== Offline so online users show a dot --- src/app/features/room/MembersDrawer.tsx | 4 +-- .../client/sidebar/AccountSwitcherTab.tsx | 36 ++++++++++++------- .../pages/client/sidebar/DirectDMsList.tsx | 21 +++++++++-- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index f751bcf31..442c51b30 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -26,7 +26,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import classNames from 'classnames'; import { AvatarPresence, PresenceBadge } from '$components/presence'; -import { useUserPresence } from '$hooks/useUserPresence'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { UseStateProvider } from '$components/UseStateProvider'; import type { SearchItemStrGetter, UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; @@ -151,7 +151,7 @@ function MemberItem({ > ) : undefined } diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index a3ec48466..ed29252e5 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -45,6 +45,8 @@ import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { useClientConfig } from '$hooks/useClientConfig'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; const log = createLogger('AccountSwitcherTab'); const debugLog = createDebugLogger('AccountSwitcherTab'); @@ -174,6 +176,7 @@ export function AccountSwitcherTab() { ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; const activeDisplayName = activeProfile.displayName; + const myPresence = useUserPresence(myUserId); const sessionProfiles = useSessionProfiles(sessions); @@ -269,19 +272,28 @@ export function AccountSwitcherTab() { {(triggerRef) => ( - 1} + + ) + } > - {nameInitials(label)}} - /> - + 1} + > + {nameInitials(label)}} + /> + + )} {(totalBackgroundUnread > 0 || anyBackgroundHighlight) && ( diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 8c3313335..31d17d68a 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -22,6 +22,8 @@ import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { useGroupDMMembers } from '$hooks/useGroupDMMembers'; import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; import * as css from './DirectDMsList.css'; const MAX_GROUP_MEMBERS = 3; @@ -44,6 +46,9 @@ function DMItem({ room, selected }: DMItemProps) { // Check if this is a group DM (more than 2 members) const isGroupDM = room.getJoinedMemberCount() > 2; + const dmUserId = !isGroupDM ? room.getAvatarFallbackMember()?.userId : undefined; + const dmPresence = useUserPresence(dmUserId ?? ''); + // Get member info for group DMs using m.direct and profile API (doesn't require full room state) // Members are sorted by who last sent messages (most recent first) const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS); @@ -135,9 +140,19 @@ function DMItem({ room, selected }: DMItemProps) { {(triggerRef) => ( - - {renderAvatar()} - + + ) + } + > + + {renderAvatar()} + + )} {unread && (unread.total > 0 || unread.highlight > 0) && ( From 7a50b64cff212c1997de5a03b24f16d8f4b0987b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 20:05:02 -0400 Subject: [PATCH 67/68] fix(presence): publish online on enable, update auto-idle description --- src/app/features/settings/general/General.tsx | 2 +- src/app/pages/client/ClientNonUIFeatures.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index e24da79ec..8608065d5 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -483,7 +483,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { {}); + } }, [mx, sendPresence]); // Auto-idle: set presence to unavailable after 5 minutes of inactivity or From 2de2315eb928eb4807e65c8a9cb37b55e197aee3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 5 May 2026 10:31:34 -0400 Subject: [PATCH 68/68] feat(presence): configurable idle timeout setting --- src/app/features/settings/general/General.tsx | 58 ++++++++++++++++++- src/app/hooks/useUserPresence.ts | 22 +++++-- src/app/pages/client/ClientNonUIFeatures.tsx | 7 ++- src/app/state/settings.ts | 4 ++ 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 8608065d5..41a67830f 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -483,7 +483,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { ) { />
)} + {sendPresence && autoIdlePresence && ( + + } + /> + + )} = (evt) => { + const val = evt.target.value; + setInputValue(val); + const parsed = Number.parseInt(val, 10); + if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 60) { + setIdleTimeoutMins(parsed); + } + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + setInputValue(idleTimeoutMins.toString()); + (evt.target as HTMLInputElement).blur(); + } + if (isKeyHotkey('enter', evt)) { + (evt.target as HTMLInputElement).blur(); + } + }; + + return ( + + + + min + + + ); +} + function Calls() { const [alwaysShowCallButton, setAlwaysShowCallButton] = useSetting( settingsAtom, diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 0c90c79f9..edfa8e32f 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; -import type { User, UserEventHandlerMap } from '$types/matrix-sdk'; -import { UserEvent } from '$types/matrix-sdk'; +import type { MatrixEvent, User, UserEventHandlerMap } from '$types/matrix-sdk'; +import { ClientEvent, UserEvent } from '$types/matrix-sdk'; import { useMatrixClient } from './useMatrixClient'; export enum Presence { @@ -31,7 +31,21 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { useEffect(() => { if (!user) { setPresence(undefined); - return undefined; + + // When the user isn't in the SDK store yet (e.g., presence arrived before + // any membership event), listen on the client for incoming events so we + // can re-evaluate once a presence event for this user is stored. + const handleEvent = (event: MatrixEvent) => { + if (event.getType() !== 'm.presence') return; + const sender = event.getSender(); + if (sender !== userId) return; + const latestUser = mx.getUser(userId); + if (latestUser) setPresence(getUserPresence(latestUser)); + }; + mx.on(ClientEvent.Event, handleEvent); + return () => { + mx.removeListener(ClientEvent.Event, handleEvent); + }; } setPresence(getUserPresence(user)); const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (e, u) => { @@ -48,7 +62,7 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { user.removeListener(UserEvent.CurrentlyActive, updatePresence); user.removeListener(UserEvent.LastPresenceTs, updatePresence); }; - }, [user]); + }, [mx, user, userId]); return presence; }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index c7dbbc5c9..efec6789d 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -845,6 +845,7 @@ function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); const [autoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence'); + const [presenceIdleTimeoutMins] = useSetting(settingsAtom, 'presenceIdleTimeoutMins'); useEffect(() => { // Classic sync: set_presence query param on every /sync poll. @@ -859,12 +860,12 @@ function PresenceFeature() { } }, [mx, sendPresence]); - // Auto-idle: set presence to unavailable after 5 minutes of inactivity or + // Auto-idle: set presence to unavailable after inactivity or // when the tab is hidden, and restore online on activity. useEffect(() => { if (!sendPresence || !autoIdlePresence) return undefined; - const IDLE_TIMEOUT_MS = 5 * 60 * 1000; + const IDLE_TIMEOUT_MS = Math.max(1, presenceIdleTimeoutMins) * 60 * 1000; let idleTimer: ReturnType | undefined; let isIdle = false; @@ -915,7 +916,7 @@ function PresenceFeature() { mx.setPresence({ presence: 'online' }).catch(() => {}); } }; - }, [mx, sendPresence, autoIdlePresence]); + }, [mx, sendPresence, autoIdlePresence, presenceIdleTimeoutMins]); return null; } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 5edd32f06..146e88984 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -73,6 +73,7 @@ export interface Settings { isWidgetDrawer: boolean; memberSortFilterIndex: number; enterForNewline: boolean; + isMarkdown: boolean; editorToolbar: boolean; composerToolbarOpen: boolean; messageLayout: MessageLayout; @@ -127,6 +128,7 @@ export interface Settings { // Sable features! sendPresence: boolean; autoIdlePresence: boolean; + presenceIdleTimeoutMins: number; showRoomMessagePreview: boolean; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; @@ -197,6 +199,7 @@ export const defaultSettings: Settings = { isWidgetDrawer: false, memberSortFilterIndex: 0, enterForNewline: false, + isMarkdown: true, editorToolbar: false, composerToolbarOpen: false, messageLayout: 0, @@ -252,6 +255,7 @@ export const defaultSettings: Settings = { // Sable features! sendPresence: true, autoIdlePresence: true, + presenceIdleTimeoutMins: 5, showRoomMessagePreview: true, mobileGestures: true, rightSwipeAction: RightSwipeAction.Reply,