Skip to content

Commit 9f5d6b7

Browse files
committed
feat(app): add collaboration carets and connection status to cloud note editor
1 parent 1db2307 commit 9f5d6b7

8 files changed

Lines changed: 177 additions & 36 deletions

File tree

apps/app/src/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export const variables = defineEnvVars({
44
PUBLIC_SERVER_URL: { public: true, static: true },
55
PUBLIC_NOTA_URL: { public: true, static: true },
66
PUBLIC_APP_URL: { public: true, static: true },
7+
PUBLIC_REALTIME_URL: { public: true, static: true },
78
});

apps/app/src/lib/data/cloud/notes.svelte.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@ export class CloudNotes {
8787

8888
async fetchByWorkspace(workspaceId: string): Promise<void> {
8989
this.#workspaceId = workspaceId;
90-
await this.#listQuery.refetch();
9190
}
9291

9392
async create(

apps/app/src/lib/data/cloud/realtime.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import CollaborationCaret from "@tiptap/extension-collaboration-caret";
44
import { Doc } from "yjs";
55
import { secureStorage } from "#lib/platform/securestorage.ts";
66
import { ISDESKTOP } from "#lib/utils.ts";
7+
import { PUBLIC_REALTIME_URL } from "$app/env/public";
78

89
const getRandomColor = () =>
910
[
@@ -23,14 +24,18 @@ const getRandomColor = () =>
2324
"#B10DC9",
2425
][Math.floor(Math.random() * 7)];
2526

26-
export const realtimeProvider = async (id: string, name: string) => {
27+
export const realtimeProvider = async (
28+
id: string,
29+
name: string,
30+
image?: string,
31+
) => {
2732
let token: string | undefined;
2833
if (ISDESKTOP) {
2934
token = await secureStorage.getItem("access_token");
3035
}
3136
const ydoc = new Doc();
3237
const provider = new HocuspocusProvider({
33-
url: "",
38+
url: PUBLIC_REALTIME_URL,
3439
name: id,
3540
document: ydoc,
3641
token,
@@ -43,7 +48,41 @@ export const realtimeProvider = async (id: string, name: string) => {
4348
Collaboration.configure({ document: ydoc, provider }),
4449
CollaborationCaret.configure({
4550
provider,
46-
user: { name, color: getRandomColor() },
51+
user: { name, color: getRandomColor(), image },
52+
render: (user) => {
53+
const cursor = document.createElement("span");
54+
cursor.classList.add("collaboration-carets__caret");
55+
cursor.setAttribute("style", `border-color: ${user.color}`);
56+
57+
const label = document.createElement("div");
58+
label.classList.add("collaboration-carets__label");
59+
label.setAttribute("style", `background-color: ${user.color}`);
60+
61+
const avatarWrapper = document.createElement("div");
62+
avatarWrapper.classList.add("collaboration-carets__avatar");
63+
64+
if (user.image) {
65+
const img = document.createElement("img");
66+
img.src = user.image;
67+
img.alt = user.name || "User";
68+
avatarWrapper.appendChild(img);
69+
} else {
70+
const initials = document.createElement("span");
71+
initials.innerText = user.name
72+
? user.name.charAt(0).toUpperCase()
73+
: "?";
74+
avatarWrapper.appendChild(initials);
75+
}
76+
77+
const nameSpan = document.createElement("span");
78+
nameSpan.classList.add("collaboration-carets__name");
79+
nameSpan.innerText = user.name || "Unknown";
80+
81+
label.appendChild(avatarWrapper);
82+
label.appendChild(nameSpan);
83+
cursor.appendChild(label);
84+
return cursor;
85+
},
4786
}),
4887
],
4988
};

apps/app/src/routes/(cloud)/note-[id]/+page.svelte

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,11 @@ import { HocuspocusProvider } from "@hocuspocus/provider";
33
import type { SelectableModel } from "@nota/ai";
44
import { toast } from "@nota/ui";
55
import { createEditor, Edra } from "@nota/ui/edra/shadcn/index.js";
6-
import type {
7-
Editor,
8-
Extension,
9-
Extensions,
10-
} from "@nota/ui/edra/tiptap/index.js";
6+
import type { Editor } from "@nota/ui/edra/tiptap/index.js";
117
import { IconPicker, IconsRenderer } from "@nota/ui/icons/index.js";
128
import { Button } from "@nota/ui/shadcn/button/index.js";
139
import { Skeleton } from "@nota/ui/shadcn/skeleton/index.js";
14-
import { onMount } from "svelte";
10+
import { onDestroy, onMount } from "svelte";
1511
import type { Doc } from "yjs";
1612
import { getAuthSession } from "#lib/auth-session.svelte.js";
1713
import { Topbar } from "#lib/components/custom/index.ts";
@@ -20,8 +16,8 @@ import { onFileUpload } from "#lib/data/cloud/storage.js";
2016
import { callAI, getUserModels } from "#lib/data/local/ai/ai.js";
2117
import { getNotesContext } from "#lib/data/notes.svelte.ts";
2218
import type { NoteMeta } from "#lib/data/types.js";
23-
import { secureStorage } from "#lib/platform/securestorage.js";
24-
import { ISDESKTOP } from "#lib/utils.js";
19+
import "./page.css";
20+
2521
import { afterNavigate, beforeNavigate } from "$app/navigation";
2622
2723
const noteCtx = getNotesContext();
@@ -32,28 +28,63 @@ let availableModels = $state<SelectableModel[]>([]);
3228
let provider = $state.raw<HocuspocusProvider>();
3329
let editor = $state.raw<Editor>();
3430
let ydoc = $state.raw<Doc>();
31+
let connectionStatus = $state("disconnected");
3532
3633
let isContentReady = $state(false);
3734
let isNotFound = $state(false);
38-
let isDirty = false;
39-
let isSaving = false;
40-
let pendingContent: any = null;
41-
let pendingText: string = "";
4235
4336
onMount(() => {
4437
getUserModels().then((models) => (availableModels = models));
4538
});
4639
47-
beforeNavigate(async () => {
48-
provider?.disconnect();
49-
editor?.destroy();
50-
ydoc?.destroy();
40+
onDestroy(() => {
41+
teardownEditor();
42+
});
43+
44+
beforeNavigate(() => {
45+
teardownEditor();
5146
});
5247
5348
afterNavigate(() => {
5449
if (data.id) loadNote(data.id);
5550
});
5651
52+
const setupEditor = async (id: string) => {
53+
teardownEditor();
54+
if (!user) {
55+
throw new Error("User not signed in");
56+
}
57+
const {
58+
ydoc: y,
59+
provider: p,
60+
extensions,
61+
} = await realtimeProvider(
62+
id,
63+
user.name ?? "Unknow",
64+
user.image ?? undefined,
65+
);
66+
ydoc = y;
67+
provider = p;
68+
69+
provider.on("status", (event: any) => {
70+
connectionStatus = event.status;
71+
});
72+
73+
editor = createEditor({
74+
collaborative: true,
75+
onFileUpload: (fileType) => onFileUpload(fileType, data.id),
76+
callAI,
77+
extensions: [...extensions],
78+
});
79+
};
80+
81+
const teardownEditor = () => {
82+
provider?.disconnect();
83+
provider?.destroy();
84+
editor?.destroy();
85+
ydoc?.destroy();
86+
};
87+
5788
const loadNote = async (id: string) => {
5889
isContentReady = false;
5990
isNotFound = false;
@@ -64,19 +95,7 @@ const loadNote = async (id: string) => {
6495
isNotFound = true;
6596
return;
6697
}
67-
const {
68-
ydoc: y,
69-
provider: p,
70-
extensions,
71-
} = await realtimeProvider(id, user?.name ?? "Unknow");
72-
ydoc = y;
73-
provider = p;
74-
editor = createEditor({
75-
collaborative: true,
76-
onFileUpload: (fileType) => onFileUpload(fileType, data.id),
77-
callAI,
78-
extensions: [...extensions],
79-
});
98+
await setupEditor(id);
8099
isContentReady = true;
81100
} catch (e) {
82101
console.error("Failed to load note:", e);
@@ -109,6 +128,14 @@ const { data } = $props();
109128
<Skeleton class="h-8 w-24" />
110129
{/if}
111130
{/snippet}
131+
{#snippet right()}
132+
<div class="flex items-center mr-4">
133+
<div class="text-xs uppercase px-2 py-1 bg-muted rounded font-medium flex items-center gap-2">
134+
<div class="w-2 h-2 rounded-full {connectionStatus === 'connected' ? 'bg-green-500' : connectionStatus === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'}"></div>
135+
{connectionStatus}
136+
</div>
137+
</div>
138+
{/snippet}
112139
</Topbar>
113140

114141
<div
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/* Give a remote user a caret */
2+
.collaboration-carets__caret {
3+
border-left: 1px solid var(--border);
4+
border-right: 1px solid var(--border);
5+
margin-left: -1px;
6+
margin-right: -1px;
7+
pointer-events: none;
8+
position: relative;
9+
word-break: normal;
10+
z-index: 10000;
11+
}
12+
13+
/* Render the username above the caret */
14+
.collaboration-carets__label {
15+
align-items: center;
16+
border-radius: 9999px;
17+
color: #0d0d0d;
18+
cursor: pointer;
19+
display: flex;
20+
font-size: 12px;
21+
font-style: normal;
22+
font-weight: 600;
23+
gap: 0;
24+
left: -1px;
25+
line-height: normal;
26+
padding: 2px;
27+
pointer-events: auto;
28+
position: absolute;
29+
top: 0;
30+
transform: translateY(-100%);
31+
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
32+
user-select: none;
33+
white-space: nowrap;
34+
z-index: 10000;
35+
}
36+
37+
.collaboration-carets__label:hover {
38+
gap: 6px;
39+
padding-right: 8px;
40+
}
41+
42+
.collaboration-carets__avatar {
43+
align-items: center;
44+
background-color: rgba(0, 0, 0, 0.1);
45+
border-radius: 50%;
46+
display: flex;
47+
flex-shrink: 0;
48+
height: 20px;
49+
justify-content: center;
50+
overflow: hidden;
51+
width: 20px;
52+
}
53+
54+
.collaboration-carets__avatar img {
55+
height: 100%;
56+
object-fit: cover;
57+
width: 100%;
58+
}
59+
60+
.collaboration-carets__avatar span {
61+
font-size: 10px;
62+
line-height: 1;
63+
}
64+
65+
.collaboration-carets__name {
66+
max-width: 0;
67+
opacity: 0;
68+
overflow: hidden;
69+
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
70+
}
71+
72+
.collaboration-carets__label:hover .collaboration-carets__name {
73+
max-width: 150px;
74+
opacity: 1;
75+
}
File renamed without changes.

apps/app/src/routes/(cloud)/space-[id]/+page.ts

Whitespace-only changes.

apps/app/src/routes/+layout.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ const session = getAuthSession();
3535
3636
let wasSignedIn = $state(false);
3737
$effect(() => {
38-
if (!ISDESKTOP && !session.isPending && !session.isRefetching) {
39-
window.location.href = `${PUBLIC_NOTA_URL}/signin`;
40-
}
38+
// if (!ISDESKTOP && !isSignedIn()) {
39+
// window.location.href = `${PUBLIC_NOTA_URL}/signin`;
40+
// }
4141
if (wasSignedIn && !isSignedIn()) {
4242
queryClient.removeQueries({ queryKey: orpc.workspace.key() });
4343
queryClient.removeQueries({ queryKey: orpc.notes.key() });

0 commit comments

Comments
 (0)