Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feat-threads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
sable: minor
---

Add thread support with side panel, browser, unread badges, and cross-device sync
5 changes: 0 additions & 5 deletions .changeset/fix_call_preferences.md

This file was deleted.

2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ updates:

- package-ecosystem: npm
cooldown:
default-days: 7
default-days: 1
directory: /
schedule:
interval: daily
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cloudflare-web-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "alias=pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
else
branch="${GITHUB_REF_NAME}"
branch="${{ github.ref_name }}"
alias="$(echo "$branch" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-\|-$//g')"
echo "alias=${alias}" >> "$GITHUB_OUTPUT"
fi
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [dev]
tags:
- 'v*'
- 'sable/v*'
pull_request:
paths:
- 'Dockerfile'
Expand Down
22 changes: 0 additions & 22 deletions .github/workflows/quality-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@ on:
pull_request:
push:
branches: [dev]
merge_group:

jobs:
format:
name: Format check
runs-on: ubuntu-latest
if: github.head_ref != 'release'
permissions:
contents: read
steps:
Expand All @@ -28,7 +26,6 @@ jobs:
lint:
name: Lint
runs-on: ubuntu-latest
if: github.head_ref != 'release'
permissions:
contents: read
steps:
Expand All @@ -46,7 +43,6 @@ jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
if: github.head_ref != 'release'
permissions:
contents: read
steps:
Expand All @@ -64,7 +60,6 @@ jobs:
knip:
name: Knip
runs-on: ubuntu-latest
if: github.head_ref != 'release'
permissions:
contents: read
steps:
Expand All @@ -78,20 +73,3 @@ jobs:

- name: Run Knip
run: pnpm run knip

build:
name: Build
runs-on: ubuntu-latest
if: github.head_ref != 'release'
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Setup app and build
uses: ./.github/actions/setup
with:
build: 'true'
2 changes: 0 additions & 2 deletions .github/workflows/require-changeset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,13 @@ name: Require Changeset
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
merge_group:
branches: [dev]

permissions: {}

jobs:
require-changeset:
runs-on: ubuntu-latest
if: github.head_ref != 'release' && github.event_name != 'merge_group'
permissions:
contents: read
pull-requests: write
Expand Down
19 changes: 19 additions & 0 deletions scripts/check-quality.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e

echo "Running quality checks..."
echo ""

echo "1/3 Checking formatting..."
pnpm run fmt:check

echo ""
echo "2/3 Running linter..."
pnpm run lint

echo ""
echo "3/3 Running type checker..."
pnpm run typecheck

echo ""
echo "✅ All quality checks passed!"
18 changes: 10 additions & 8 deletions src/app/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,6 @@ import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';

const initialValue: CustomElement[] = [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
},
];

const withInline = (editor: Editor): Editor => {
const { isInline } = editor;

Expand Down Expand Up @@ -96,6 +89,15 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
},
ref
) => {
// Each <Slate> instance must receive its own fresh node objects.
// Sharing a module-level constant causes Slate's global NODE_TO_ELEMENT
// WeakMap to be overwritten when multiple editors are mounted at the same
// time (e.g. RoomInput + MessageEditor in the thread drawer), leading to
// "Unable to find the path for Slate node" crashes.
const [slateInitialValue] = useState<CustomElement[]>(() => [
{ type: BlockType.Paragraph, children: [{ text: '' }] },
]);

const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />,
[]
Expand Down Expand Up @@ -132,7 +134,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(

return (
<div className={`${css.Editor} ${className || ''}`} ref={ref}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
<Slate editor={editor} initialValue={slateInitialValue} onChange={onChange}>
{top}
<Box alignItems="Start">
{before && (
Expand Down
21 changes: 7 additions & 14 deletions src/app/features/call/CallControls.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import { MouseEventHandler, useCallback, useRef, useState } from 'react';
import {
Box,
Button,
Expand All @@ -15,13 +15,7 @@ import {
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '$components/sequence-card';
import { CallEmbed, useCallControlState } from '$plugins/call';
import { stopPropagation } from '$utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { useRoom } from '$hooks/useRoom';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useCallPreferences } from '$state/hooks/callPreferences';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
import {
ChatButton,
Expand All @@ -31,6 +25,11 @@ import {
SoundButton,
VideoButton,
} from './Controls';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useRoom } from '../../hooks/useRoom';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';

type CallControlsProps = {
callEmbed: CallEmbed;
Expand All @@ -46,12 +45,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
callEmbed.control
);

const { setPreferences } = useCallPreferences();

useEffect(() => {
setPreferences({ microphone, video, sound });
}, [microphone, video, sound, setPreferences]);

const [cords, setCords] = useState<RectCords>();

const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
Expand Down
80 changes: 77 additions & 3 deletions src/app/features/room/Room.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
Expand All @@ -15,10 +15,14 @@ import { useRoomMembers } from '$hooks/useRoomMembers';
import { CallView } from '$features/call/CallView';
import { WidgetsDrawer } from '$features/widgets/WidgetsDrawer';
import { callChatAtom } from '$state/callEmbed';
import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread';
import { roomIdToThreadBrowserAtomFamily } from '$state/room/roomToThreadBrowser';
import { RoomViewHeader } from './RoomViewHeader';
import { MembersDrawer } from './MembersDrawer';
import { RoomView } from './RoomView';
import { CallChatView } from './CallChatView';
import { ThreadDrawer } from './ThreadDrawer';
import { ThreadBrowser } from './ThreadBrowser';

export function Room() {
const { eventId } = useParams();
Expand All @@ -32,6 +36,30 @@ export function Room() {
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const chat = useAtomValue(callChatAtom);
const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId));
const [threadBrowserOpen, setThreadBrowserOpen] = useAtom(
roomIdToThreadBrowserAtomFamily(room.roomId)
);

// If navigating to an event in a thread, open the thread drawer
useEffect(() => {
if (!eventId) return;

const event = room.findEventById(eventId);
if (!event) return;

const { threadRootId } = event;
if (threadRootId) {
// Ensure Thread object exists
if (!room.getThread(threadRootId)) {
const rootEvent = room.findEventById(threadRootId);
if (rootEvent) {
room.createThread(threadRootId, rootEvent, [], false);
}
}
setOpenThread(threadRootId);
}
}, [eventId, room, setOpenThread]);

useKeyDown(
window,
Expand All @@ -49,7 +77,7 @@ export function Room() {

return (
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
<Box grow="Yes" style={{ position: 'relative' }}>
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
<Box grow="Yes" direction="Column">
<RoomViewHeader callView />
Expand Down Expand Up @@ -87,6 +115,52 @@ export function Room() {
<WidgetsDrawer key={`widgets-${room.roomId}`} room={room} />
</>
)}
{screenSize === ScreenSize.Desktop && openThreadId && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<ThreadDrawer
key={`thread-${room.roomId}-${openThreadId}`}
room={room}
threadRootId={openThreadId}
onClose={() => setOpenThread(undefined)}
/>
</>
)}
{screenSize === ScreenSize.Desktop && threadBrowserOpen && !openThreadId && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<ThreadBrowser
key={`thread-browser-${room.roomId}`}
room={room}
onOpenThread={(id) => {
setOpenThread(id);
setThreadBrowserOpen(false);
}}
onClose={() => setThreadBrowserOpen(false)}
/>
</>
)}
{screenSize !== ScreenSize.Desktop && openThreadId && (
<ThreadDrawer
key={`thread-${room.roomId}-${openThreadId}`}
room={room}
threadRootId={openThreadId}
onClose={() => setOpenThread(undefined)}
overlay
/>
)}
{screenSize !== ScreenSize.Desktop && threadBrowserOpen && !openThreadId && (
<ThreadBrowser
key={`thread-browser-${room.roomId}`}
room={room}
onOpenThread={(id) => {
setOpenThread(id);
setThreadBrowserOpen(false);
}}
onClose={() => setThreadBrowserOpen(false)}
overlay
/>
)}
</Box>
</PowerLevelsContextProvider>
);
Expand Down
Loading
Loading