Notes: live folder-sharing (read-only) — Task #445

Owner can share a whole folder with other users; recipients see it
under "Shared with me" in the folders rail and view notes read-only
(cannot edit, add, move, or delete).

- DB: new noteFolderSharesTable (folderId/recipientUserId, cascade,
  unique idx). Pushed via drizzle-kit.
- API (artifacts/api-server/src/routes/notes.ts):
  - GET/PATCH/POST /note-folders return sharedWithCount.
  - GET /note-folders/shared-with-me, GET /note-folders/:id/shares,
    PUT /note-folders/:id/shares (idempotent diff), DELETE
    /note-folders/:id/shares/:userId, GET /note-folders/:id/shared-notes
    (verifies recipient via noteFolderSharesTable, stamps readOnly).
  - Emits note-folder-shared / note-folder-unshared on share changes.
  - Existing note write paths remain owner-scoped → recipients can't
    mutate owner notes.
- Frontend types/hooks (notes-api.ts): SharedFolder, FolderShareRecipient,
  SharedFolderNote/View; useFolderShares, useUpdateFolderShares,
  useSharedWithMeFolders, useSharedFolderNotes.
- FoldersRail: Share menu item, sharedWithCount badge, "Shared with me"
  section, "shared-folder" selection kind, onShareFolder prop.
- notes.tsx: SharedFolderView (no Composer, read-only cards using
  ChecklistView), FolderShareDialog (seeds existing recipients,
  idempotent PUT on save), revocation fallback effect, dialog mount.
- use-notifications-socket.ts: subscribe to note-folder-shared /
  -unshared → invalidate shared-with-me + open shared-folder notes for
  live rail/view refresh.
- i18n: AR/EN keys for share/sharedBy/sharedByName/sharedWithCount/
  sharedWithMe/readOnly/shareRevoked/sharedEmpty/shareSaved/shareFailed.

Pre-existing failing `test` workflow (executive-meetings type errors)
is unrelated and out of scope.
This commit is contained in:
riyadhafraa
2026-05-09 11:07:30 +00:00
parent e287278819
commit a4949983f3
8 changed files with 924 additions and 16 deletions
+303 -4
View File
@@ -8,6 +8,7 @@ import {
noteLabelAssignmentsTable,
noteRecipientsTable,
noteRepliesTable,
noteFolderSharesTable,
usersTable,
notificationsTable,
} from "@workspace/db";
@@ -1136,7 +1137,7 @@ router.get("/note-folders", requireAuth, async (req, res): Promise<void> => {
eq(notesTable.userId, userId),
eq(notesTable.isArchived, archived),
);
const [folders, counts] = await Promise.all([
const [folders, counts, shareCounts] = await Promise.all([
db
.select()
.from(noteFoldersTable)
@@ -1150,12 +1151,34 @@ router.get("/note-folders", requireAuth, async (req, res): Promise<void> => {
.from(notesTable)
.where(noteScope)
.groupBy(notesTable.folderId),
db
.select({
folderId: noteFolderSharesTable.folderId,
count: sql<number>`count(*)::int`,
})
.from(noteFolderSharesTable)
.innerJoin(
noteFoldersTable,
eq(noteFolderSharesTable.folderId, noteFoldersTable.id),
)
.where(eq(noteFoldersTable.userId, userId))
.groupBy(noteFolderSharesTable.folderId),
]);
const countMap = new Map<number, number>();
for (const row of counts) {
if (row.folderId != null) countMap.set(row.folderId, Number(row.count));
}
res.json(folders.map((f) => ({ ...f, noteCount: countMap.get(f.id) ?? 0 })));
const shareCountMap = new Map<number, number>();
for (const row of shareCounts) {
shareCountMap.set(row.folderId, Number(row.count));
}
res.json(
folders.map((f) => ({
...f,
noteCount: countMap.get(f.id) ?? 0,
sharedWithCount: shareCountMap.get(f.id) ?? 0,
})),
);
});
router.post("/note-folders", requireAuth, async (req, res): Promise<void> => {
@@ -1170,7 +1193,7 @@ router.post("/note-folders", requireAuth, async (req, res): Promise<void> => {
.insert(noteFoldersTable)
.values({ userId, name: parsed.name })
.returning();
res.status(201).json({ ...folder, noteCount: 0 });
res.status(201).json({ ...folder, noteCount: 0, sharedWithCount: 0 });
} catch (e) {
if (isUniqueViolation(e)) {
res.status(409).json({ error: "Folder already exists" });
@@ -1215,7 +1238,15 @@ router.patch("/note-folders/:id", requireAuth, async (req, res): Promise<void> =
eq(notesTable.folderId, folder.id),
),
);
res.json({ ...folder, noteCount: Number(countRow?.count ?? 0) });
const [shareRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(noteFolderSharesTable)
.where(eq(noteFolderSharesTable.folderId, folder.id));
res.json({
...folder,
noteCount: Number(countRow?.count ?? 0),
sharedWithCount: Number(shareRow?.count ?? 0),
});
} catch (e) {
if (isUniqueViolation(e)) {
res.status(409).json({ error: "Folder already exists" });
@@ -1243,4 +1274,272 @@ router.delete("/note-folders/:id", requireAuth, async (req, res): Promise<void>
res.json({ success: true });
});
// ---------- Folder-level live sharing ----------
//
// A folder owner can share a folder read-only with other users. Recipients see
// the owner's CURRENT notes inside the folder (live, not snapshot). They cannot
// edit, add, move, or delete anything; the existing per-note routes already
// filter by `notesTable.userId = req.session.userId`, so any write attempt by a
// recipient just 404s — no extra gating required there.
router.get(
"/note-folders/shared-with-me",
requireAuth,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const rows = await db
.select({
id: noteFoldersTable.id,
name: noteFoldersTable.name,
ownerId: noteFoldersTable.userId,
sharedAt: noteFolderSharesTable.sharedAt,
ownerUsername: usersTable.username,
ownerDisplayNameAr: usersTable.displayNameAr,
ownerDisplayNameEn: usersTable.displayNameEn,
})
.from(noteFolderSharesTable)
.innerJoin(
noteFoldersTable,
eq(noteFolderSharesTable.folderId, noteFoldersTable.id),
)
.innerJoin(usersTable, eq(noteFoldersTable.userId, usersTable.id))
.where(eq(noteFolderSharesTable.recipientUserId, userId))
.orderBy(noteFoldersTable.name);
res.json(rows);
},
);
router.get(
"/note-folders/:id/shares",
requireAuth,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
if (!id.ok) {
res.status(400).json({ error: id.error });
return;
}
if (!(await userOwnsFolder(userId, id.id))) {
res.status(404).json({ error: "Folder not found" });
return;
}
const rows = await db
.select({
userId: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
sharedAt: noteFolderSharesTable.sharedAt,
})
.from(noteFolderSharesTable)
.innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id))
.where(eq(noteFolderSharesTable.folderId, id.id))
.orderBy(noteFolderSharesTable.sharedAt);
res.json(rows);
},
);
router.put(
"/note-folders/:id/shares",
requireAuth,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
if (!id.ok) {
res.status(400).json({ error: id.error });
return;
}
if (!(await userOwnsFolder(userId, id.id))) {
res.status(404).json({ error: "Folder not found" });
return;
}
const body = req.body;
if (!body || typeof body !== "object" || !Array.isArray(body.recipientUserIds)) {
res.status(400).json({ error: "Invalid recipientUserIds" });
return;
}
const desired = new Set<number>();
for (const x of body.recipientUserIds) {
const n = Number(x);
if (Number.isInteger(n) && n > 0 && n !== userId) desired.add(n);
}
// Validate that every desired recipient actually exists.
if (desired.size > 0) {
const valid = await db
.select({ id: usersTable.id })
.from(usersTable)
.where(inArray(usersTable.id, Array.from(desired)));
const validIds = new Set(valid.map((r) => r.id));
for (const id of Array.from(desired)) {
if (!validIds.has(id)) desired.delete(id);
}
}
// Diff current vs desired and apply incrementally so repeated PUTs are
// idempotent and we only emit notifications for genuinely new recipients.
const current = await db
.select({ recipientUserId: noteFolderSharesTable.recipientUserId })
.from(noteFolderSharesTable)
.where(eq(noteFolderSharesTable.folderId, id.id));
const currentSet = new Set(current.map((r) => r.recipientUserId));
const toAdd: number[] = [];
const toRemove: number[] = [];
for (const r of desired) if (!currentSet.has(r)) toAdd.push(r);
for (const r of currentSet) if (!desired.has(r)) toRemove.push(r);
if (toAdd.length > 0) {
await db
.insert(noteFolderSharesTable)
.values(toAdd.map((rid) => ({ folderId: id.id, recipientUserId: rid })))
.onConflictDoNothing();
}
if (toRemove.length > 0) {
await db
.delete(noteFolderSharesTable)
.where(
and(
eq(noteFolderSharesTable.folderId, id.id),
inArray(noteFolderSharesTable.recipientUserId, toRemove),
),
);
}
// Emit live socket events so recipients' rails refresh without polling.
for (const rid of toAdd) {
void emitToUser(rid, "note-folder-shared", { folderId: id.id });
}
for (const rid of toRemove) {
void emitToUser(rid, "note-folder-unshared", { folderId: id.id });
}
const rows = await db
.select({
userId: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
sharedAt: noteFolderSharesTable.sharedAt,
})
.from(noteFolderSharesTable)
.innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id))
.where(eq(noteFolderSharesTable.folderId, id.id))
.orderBy(noteFolderSharesTable.sharedAt);
res.json(rows);
},
);
router.delete(
"/note-folders/:id/shares/:userId",
requireAuth,
async (req, res): Promise<void> => {
const ownerId = req.session.userId!;
const id = parseIdParam(req.params.id);
const target = parseIdParam(req.params.userId);
if (!id.ok || !target.ok) {
res.status(400).json({ error: "Invalid id" });
return;
}
if (!(await userOwnsFolder(ownerId, id.id))) {
res.status(404).json({ error: "Folder not found" });
return;
}
await db
.delete(noteFolderSharesTable)
.where(
and(
eq(noteFolderSharesTable.folderId, id.id),
eq(noteFolderSharesTable.recipientUserId, target.id),
),
);
void emitToUser(target.id, "note-folder-unshared", { folderId: id.id });
res.json({ success: true });
},
);
/**
* GET /note-folders/:id/shared-notes
*
* Recipient view of a shared folder. Returns the OWNER's current notes inside
* the folder (live join — not a snapshot), with `readOnly: true` so the client
* knows to disable every write affordance. 403 if the caller is not a current
* recipient of the share.
*/
router.get(
"/note-folders/:id/shared-notes",
requireAuth,
async (req, res): Promise<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
if (!id.ok) {
res.status(400).json({ error: id.error });
return;
}
const [share] = await db
.select({ folderId: noteFolderSharesTable.folderId })
.from(noteFolderSharesTable)
.where(
and(
eq(noteFolderSharesTable.folderId, id.id),
eq(noteFolderSharesTable.recipientUserId, userId),
),
);
if (!share) {
res.status(403).json({ error: "Forbidden" });
return;
}
const [folder] = await db
.select()
.from(noteFoldersTable)
.where(eq(noteFoldersTable.id, id.id));
if (!folder) {
res.status(404).json({ error: "Folder not found" });
return;
}
const notes = await db
.select()
.from(notesTable)
.where(
and(
eq(notesTable.userId, folder.userId),
eq(notesTable.folderId, folder.id),
eq(notesTable.isArchived, false),
),
)
.orderBy(desc(notesTable.isPinned), desc(notesTable.updatedAt));
let withLabels: Array<(typeof notes)[number] & { labelIds: number[] }> = [];
if (notes.length > 0) {
const ids = notes.map((n) => n.id);
const assignments = await db
.select()
.from(noteLabelAssignmentsTable)
.where(inArray(noteLabelAssignmentsTable.noteId, ids));
const map = new Map<number, number[]>();
for (const a of assignments) {
const arr = map.get(a.noteId) ?? [];
arr.push(a.labelId);
map.set(a.noteId, arr);
}
withLabels = notes.map((n) => ({ ...n, labelIds: map.get(n.id) ?? [] }));
}
const [owner] = await db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
})
.from(usersTable)
.where(eq(usersTable.id, folder.userId));
res.json({
folder: { id: folder.id, name: folder.name, ownerId: folder.userId },
owner: owner ?? null,
notes: withLabels.map((n) => ({ ...n, readOnly: true as const })),
});
},
);
export default router;
@@ -8,7 +8,9 @@ import {
Layers,
MoreVertical,
Pencil,
Share2,
Trash2,
Users,
Check,
X,
} from "lucide-react";
@@ -32,20 +34,32 @@ import {
} from "@/components/ui/alert-dialog";
import {
type NoteFolder,
type SharedFolder,
useCreateFolder,
useDeleteFolder,
useSharedWithMeFolders,
useUpdateFolder,
userDisplayName,
} from "@/lib/notes-api";
import { useToast } from "@/hooks/use-toast";
export type FolderSelection =
| { kind: "all" }
| { kind: "unfiled" }
| { kind: "folder"; id: number };
| { kind: "folder"; id: number }
// A folder shared WITH the current user. Distinct kind because the
// notes inside come from a different endpoint, the rail row carries
// the owner's display name, and every write affordance is hidden.
| { kind: "shared-folder"; id: number };
export function selectionMatches(a: FolderSelection, b: FolderSelection): boolean {
if (a.kind !== b.kind) return false;
if (a.kind === "folder" && b.kind === "folder") return a.id === b.id;
if (
(a.kind === "folder" && b.kind === "folder") ||
(a.kind === "shared-folder" && b.kind === "shared-folder")
) {
return a.id === b.id;
}
return true;
}
@@ -130,6 +144,9 @@ interface Props {
unfiledCount: number;
totalCount: number;
onDropNote: (target: "unfiled" | number) => void;
// Owner action: open the folder-share dialog. The page handles mounting
// <FolderShareDialog> so the rail stays focused on navigation/selection.
onShareFolder?: (folder: NoteFolder) => void;
// "rail" → full sidebar / chip rail with folder rows (default).
// "chips-only" → render only the All-notes / Unfiled / "+ New folder"
// chips. Folder rows are owned by the FoldersBrowser in the
@@ -144,9 +161,14 @@ export function FoldersRail({
unfiledCount,
totalCount,
onDropNote,
onShareFolder,
mode = "rail",
}: Props) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Folders shared WITH the current user. Rendered in a dedicated section
// below the owner's folders so the two namespaces are visually distinct.
const { data: sharedFolders = [] } = useSharedWithMeFolders();
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
@@ -398,6 +420,24 @@ export function FoldersRail({
>
{f.noteCount}
</span>
{f.sharedWithCount > 0 && (
<span
className={`inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full ${
isSel
? "bg-background/20 text-background"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
}`}
title={t("notes.folders.sharedWithCount", {
defaultValue_one: "Shared with 1 person",
defaultValue_other: "Shared with {{count}} people",
count: f.sharedWithCount,
})}
data-testid={`folder-shared-badge-${f.id}`}
>
<Users size={10} />
{f.sharedWithCount}
</span>
)}
</button>
<div className="flex items-center pe-1">
<DropdownMenu>
@@ -414,7 +454,7 @@ export function FoldersRail({
<MoreVertical size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onSelect={() => {
setEditingId(f.id);
@@ -425,6 +465,15 @@ export function FoldersRail({
<Pencil size={14} className="me-2" />
{t("notes.folders.rename", "Rename folder")}
</DropdownMenuItem>
{onShareFolder && (
<DropdownMenuItem
onSelect={() => onShareFolder(f)}
data-testid={`folder-share-${f.id}`}
>
<Share2 size={14} className="me-2" />
{t("notes.folders.share", "Share folder")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => setPendingDelete(f)}
className="text-red-600 focus:text-red-600"
@@ -483,6 +532,64 @@ export function FoldersRail({
</button>
)}
{mode === "rail" && sharedFolders.length > 0 && (
<div
className="contents md:block md:pt-3"
data-testid="shared-with-me-section"
>
<div className="hidden md:block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground px-2 mb-1 mt-3">
{t("notes.folders.sharedWithMe", "Shared with me")}
</div>
{sharedFolders.map((sf: SharedFolder) => {
const isSel =
selected.kind === "shared-folder" && selected.id === sf.id;
const ownerName = userDisplayName(
{
id: sf.ownerId,
username: sf.ownerUsername,
displayNameAr: sf.ownerDisplayNameAr,
displayNameEn: sf.ownerDisplayNameEn,
},
lang,
);
return (
<div
key={`shared:${sf.id}`}
className={`group rounded-lg border transition shrink-0 md:shrink ${
isSel
? "bg-foreground text-background border-foreground"
: "border-transparent hover:bg-slate-100"
}`}
data-testid={`shared-folder-row-${sf.id}`}
>
<button
type="button"
onClick={() => onSelect({ kind: "shared-folder", id: sf.id })}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm text-start whitespace-nowrap"
data-testid={`shared-folder-select-${sf.id}`}
>
<span className="shrink-0">
<Share2 size={14} />
</span>
<span className="flex-1 min-w-0">
<span className="block truncate">{sf.name}</span>
<span
className={`block truncate text-[10px] ${
isSel ? "text-background/80" : "text-muted-foreground"
}`}
>
{t("notes.folders.sharedByName", "by {{name}}", {
name: ownerName,
})}
</span>
</span>
</button>
</div>
);
})}
</div>
)}
<div className="hidden md:block text-[10px] text-muted-foreground/80 px-2 pt-1 leading-snug">
{t(
"notes.folders.dragHint",
@@ -323,6 +323,30 @@ export function useNotificationsSocket() {
},
);
// Folder-sharing live updates (#445). Owner shares/unshares a folder
// with this user → invalidate the rail's "Shared with me" list, and any
// open shared-folder note view for that folder so it either refreshes
// (still shared) or surfaces the revoked empty state (unshared) without
// a manual reload.
socket.on(
"note-folder-shared",
(payload?: { folderId?: number }) => {
invalidate(["note-folders", "shared-with-me"]);
if (typeof payload?.folderId === "number") {
invalidate(["notes", "shared-folder", payload.folderId]);
}
},
);
socket.on(
"note-folder-unshared",
(payload?: { folderId?: number }) => {
invalidate(["note-folders", "shared-with-me"]);
if (typeof payload?.folderId === "number") {
invalidate(["notes", "shared-folder", payload.folderId]);
}
},
);
socket.on("apps_changed", () => {
invalidate(getListAppsQueryKey());
invalidate(getGetMeQueryKey());
+102
View File
@@ -36,10 +36,39 @@ export interface NoteFolder {
userId: number;
name: string;
noteCount: number;
// Number of distinct recipients the owner has shared this folder with.
// 0 when the folder is not shared. Always present on the wire (server
// defaults missing rows to 0), so the rail can render a "shared" badge
// unconditionally without tristate logic.
sharedWithCount: number;
createdAt: string;
updatedAt: string;
}
// A folder shared WITH the current user by another user. Returned by
// GET /note-folders/shared-with-me. Distinct from `NoteFolder` because the
// caller is not the owner: there are no per-tab note counts (notes change
// live as the owner edits) and the owner's display info travels with the row
// so the rail can render "by <name>" without an extra fetch.
export interface SharedFolder {
id: number;
name: string;
ownerId: number;
sharedAt: string;
ownerUsername: string;
ownerDisplayNameAr: string | null;
ownerDisplayNameEn: string | null;
}
// One row in a folder's share list (people the owner has shared TO).
export interface FolderShareRecipient {
userId: number;
username: string;
displayNameAr: string | null;
displayNameEn: string | null;
sharedAt: string;
}
export interface UserSummary {
id: number;
username: string;
@@ -518,6 +547,79 @@ export function useMoveNoteToFolder() {
});
}
// ---------- Folder sharing ----------
export const folderSharesKey = (folderId: number) =>
["note-folder-shares", folderId] as const;
export const sharedWithMeKey = ["note-folders", "shared-with-me"] as const;
export const sharedFolderNotesKey = (folderId: number) =>
["notes", "shared-folder", folderId] as const;
// People the OWNER has shared a given folder with. 404s for non-owners.
export function useFolderShares(folderId: number | null) {
return useQuery({
queryKey: folderSharesKey(folderId ?? 0),
queryFn: () =>
req<FolderShareRecipient[]>(`/note-folders/${folderId}/shares`),
enabled: folderId != null,
});
}
// Replace the recipient set on a folder. Server diffs idempotently so this
// can be called repeatedly with the desired final set.
export function useUpdateFolderShares() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
folderId,
recipientUserIds,
}: {
folderId: number;
recipientUserIds: number[];
}) =>
req<FolderShareRecipient[]>(`/note-folders/${folderId}/shares`, {
method: "PUT",
body: JSON.stringify({ recipientUserIds }),
}),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: folderSharesKey(vars.folderId) });
// Owner's folder list shows the "shared with N" badge — refresh it.
qc.invalidateQueries({ queryKey: ["note-folders"] });
},
});
}
// Folders shared WITH the current user.
export function useSharedWithMeFolders() {
return useQuery({
queryKey: sharedWithMeKey,
queryFn: () => req<SharedFolder[]>(`/note-folders/shared-with-me`),
});
}
// Note returned inside a shared-folder view. Carries the readOnly flag the
// server stamps on every record so the UI doesn't have to recompute access.
export interface SharedFolderNote extends Note {
readOnly: true;
}
export interface SharedFolderView {
folder: { id: number; name: string; ownerId: number };
owner: UserSummary | null;
notes: SharedFolderNote[];
}
// Notes inside a folder shared with the caller. Live (owner's current
// notes), not a snapshot.
export function useSharedFolderNotes(folderId: number | null) {
return useQuery({
queryKey: sharedFolderNotesKey(folderId ?? 0),
queryFn: () =>
req<SharedFolderView>(`/note-folders/${folderId}/shared-notes`),
enabled: folderId != null,
});
}
export function useDeleteLabel() {
const qc = useQueryClient();
return useMutation({
+19 -1
View File
@@ -1063,7 +1063,25 @@
"bulkDeleteTitle": "حذف المجلدات المحددة؟",
"bulkDeleteConfirm": "ستنتقل الملاحظات داخل هذه المجلدات إلى \"بدون مجلد\".",
"bulkDeleteFailed": "تعذّر حذف بعض المجلدات",
"backToFolders": "العودة إلى المجلدات"
"backToFolders": "العودة إلى المجلدات",
"share": "مشاركة المجلد",
"shareTitle": "مشاركة المجلد",
"shareDescription": "يمكن للمستلمين عرض الملاحظات في هذا المجلد دون تعديلها.",
"shareSaved": "تم تحديث المشاركة",
"shareFailed": "تعذّر تحديث المشاركة",
"shareRevoked": "لم يعد هذا المجلد مشاركاً معك",
"sharedEmpty": "لا توجد ملاحظات في هذا المجلد بعد",
"sharedWithMe": "مشاركة معي",
"sharedBy": "مشاركة من",
"sharedByName": "بواسطة {{name}}",
"sharedWithCount_zero": "غير مشارك",
"sharedWithCount_one": "مشاركة مع شخص واحد",
"sharedWithCount_two": "مشاركة مع شخصين",
"sharedWithCount_few": "مشاركة مع {{count}} أشخاص",
"sharedWithCount_many": "مشاركة مع {{count}} شخصاً",
"sharedWithCount_other": "مشاركة مع {{count}} شخص",
"sharedWithCount": "مشاركة مع {{count}} أشخاص",
"readOnly": "للقراءة فقط"
},
"tabs": {
"active": "ملاحظاتي",
+15 -1
View File
@@ -964,7 +964,21 @@
"bulkDeleteTitle": "Delete selected folders?",
"bulkDeleteConfirm": "Notes inside these folders will move to Unfiled.",
"bulkDeleteFailed": "Some folders could not be deleted",
"backToFolders": "Back to folders"
"backToFolders": "Back to folders",
"share": "Share folder",
"shareTitle": "Share folder",
"shareDescription": "Recipients can view but not edit notes in this folder.",
"shareSaved": "Sharing updated",
"shareFailed": "Could not update sharing",
"shareRevoked": "This folder is no longer shared with you",
"sharedEmpty": "This folder has no notes yet",
"sharedWithMe": "Shared with me",
"sharedBy": "Shared by",
"sharedByName": "by {{name}}",
"sharedWithCount_one": "Shared with {{count}} person",
"sharedWithCount_other": "Shared with {{count}} people",
"sharedWithCount": "Shared with {{count}} people",
"readOnly": "Read-only"
},
"tabs": {
"active": "My Notes",
+322 -6
View File
@@ -26,6 +26,9 @@ import {
Pencil,
MoreVertical,
ChevronLeft,
Share2,
Users,
Lock,
} from "lucide-react";
import {
DropdownMenu,
@@ -98,6 +101,11 @@ import {
useUpdateNote,
useUserDirectory,
userDisplayName,
useFolderShares,
useSharedFolderNotes,
useSharedWithMeFolders,
useUpdateFolderShares,
type FolderShareRecipient,
} from "@/lib/notes-api";
import {
FoldersRail,
@@ -196,18 +204,29 @@ export default function NotesPage() {
const { data: notes = [], isLoading: loadingNotes } = useNotes(view === "archived");
const { data: labels = [] } = useNoteLabels();
const { data: folders = [] } = useNoteFolders(view === "archived");
const { data: sharedFolders = [] } = useSharedWithMeFolders();
const moveNote = useMoveNoteToFolder();
const showFolders = view === "active" || view === "archived";
// Folder the share dialog is currently open for. `null` → dialog closed.
// Owned folder only — recipients never see this entry point.
const [folderShareFor, setFolderShareFor] = useState<NoteFolder | null>(null);
// If the user switches tabs (or a folder is deleted) such that the current
// selection no longer exists in this view's folder set, fall back to "all"
// so they don't get stuck on an empty list.
// so they don't get stuck on an empty list. Same guard for "shared-folder"
// selections when the owner revokes the share.
useEffect(() => {
if (folderSelection.kind !== "folder") return;
if (!folders.some((f) => f.id === folderSelection.id)) {
setFolderSelection({ kind: "all" });
if (folderSelection.kind === "folder") {
if (!folders.some((f) => f.id === folderSelection.id)) {
setFolderSelection({ kind: "all" });
}
} else if (folderSelection.kind === "shared-folder") {
if (!sharedFolders.some((f) => f.id === folderSelection.id)) {
setFolderSelection({ kind: "all" });
}
}
}, [folders, folderSelection]);
}, [folders, sharedFolders, folderSelection]);
const { data: receivedNotes = [], isLoading: loadingReceived } =
useReceivedNotes(false);
const { data: archivedReceivedNotes = [], isLoading: loadingArchivedReceived } =
@@ -446,11 +465,23 @@ export default function NotesPage() {
unfiledCount={unfiledCount}
totalCount={notes.length}
onDropNote={handleDropNote}
onShareFolder={(f) => setFolderShareFor(f)}
mode={view === "active" && folderSelection.kind === "all" ? "chips-only" : "rail"}
/>
)}
<div className="flex-1 min-w-0">
{view === "active" && (
{view === "active" && folderSelection.kind === "shared-folder" && (
// Recipient view of a folder shared with the current user. Rendered
// before the regular active branch so we never mount the Composer
// (recipients are read-only) or run the owner-only filteredNotes
// pipeline against the wrong note set.
<SharedFolderView
folderId={folderSelection.id}
labels={labels}
onBack={() => setFolderSelection({ kind: "all" })}
/>
)}
{view === "active" && folderSelection.kind !== "shared-folder" && (
<>
{folderSelection.kind === "all" && (
<FoldersBrowser
@@ -570,6 +601,13 @@ export default function NotesPage() {
/>
)}
{folderShareFor && (
<FolderShareDialog
folder={folderShareFor}
onClose={() => setFolderShareFor(null)}
/>
)}
{openThreadId !== null && (
<ThreadDialog
noteId={openThreadId}
@@ -2914,3 +2952,281 @@ function LabelsDialog({
</Dialog>
);
}
// Recipient view of a folder shared with the current user.
//
// Strict read-only: no Composer, no edit/send/archive/delete affordances on
// cards. Notes are rendered with the same styling as the inbox/sent grids
// (which are themselves read-only) so the visual language stays familiar
// without us having to thread a `readOnly` flag through the full NoteCard.
// Drives the "Shared with me" entry in the folders rail and is unmounted
// the moment the owner revokes access (parent effect falls back to "all").
function SharedFolderView({
folderId,
labels: _labels,
onBack,
}: {
folderId: number;
labels: NoteLabel[];
onBack: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { data, isLoading, error } = useSharedFolderNotes(folderId);
if (isLoading) return <Loading />;
// 403/404 — the owner unshared the folder while we were viewing it. Show a
// brief "no longer shared" empty state; the parent effect will switch the
// selection back to "all" on the next sharedFolders tick.
if (error || !data) {
return (
<>
<button
type="button"
onClick={onBack}
className="mb-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
data-testid="notes-folders-back"
>
<ChevronLeft size={14} className="rtl:rotate-180" />
<span>{t("notes.folders.backToFolders", "Back to folders")}</span>
</button>
<Empty
icon={<Lock size={48} />}
text={t(
"notes.folders.shareRevoked",
"This folder is no longer shared with you",
)}
/>
</>
);
}
return (
<>
<button
type="button"
onClick={onBack}
className="mb-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
data-testid="notes-folders-back"
>
<ChevronLeft size={14} className="rtl:rotate-180" />
<span>{t("notes.folders.backToFolders", "Back to folders")}</span>
<span className="text-foreground/80 font-medium ms-1">
{data.folder.name}
</span>
</button>
<div
className="mb-4 flex items-center gap-2 text-xs text-muted-foreground rounded-lg bg-slate-50 dark:bg-slate-900/40 border border-slate-200/70 dark:border-slate-700/60 px-3 py-2"
data-testid="shared-folder-banner"
>
<Lock size={12} />
<span>
{t("notes.folders.sharedBy", "Shared by")}{" "}
<span className="text-foreground/80 font-medium">
{userDisplayName(data.owner, lang)}
</span>
{" · "}
{t("notes.folders.readOnly", "Read-only")}
</span>
</div>
{data.notes.length === 0 ? (
<Empty
icon={<StickyNote size={48} />}
text={t("notes.folders.sharedEmpty", "This folder has no notes yet")}
/>
) : (
<div
className="gap-3 mt-2 [column-fill:_balance]"
style={{ columnWidth: 260 }}
data-testid="shared-folder-notes"
>
{data.notes.map((n) => (
<div
key={n.id}
data-testid={`shared-folder-note-${n.id}`}
className={`text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 ${colorBg(
n.color,
)}`}
>
{n.title && (
<div className="font-semibold text-sm text-foreground break-words">
{n.title}
</div>
)}
{n.kind === "checklist" ? (
<ChecklistView
items={n.items ?? []}
testIdPrefix={`shared-folder-note-checklist-${n.id}`}
className="mt-1"
noteColor={n.color}
/>
) : (
n.content && (
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
{n.content}
</div>
)
)}
</div>
))}
</div>
)}
</>
);
}
// Owner-side dialog: pick which users this folder is shared with.
//
// Renders the directory minus self; pre-checks anyone already in the share
// list. "Save" calls the idempotent PUT endpoint with the full desired set
// — server diffs adds/removes and broadcasts share/unshare events so
// recipients' rails update live. Closing without changes is a no-op.
function FolderShareDialog({
folder,
onClose,
}: {
folder: NoteFolder;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { user } = useAuth();
const { data: directory = [], isLoading: loadingDir } = useUserDirectory();
const { data: existing = [], isLoading: loadingShares } = useFolderShares(
folder.id,
);
const update = useUpdateFolderShares();
const [search, setSearch] = useState("");
const [picked, setPicked] = useState<Set<number> | null>(null);
// Seed the picked set from the existing shares once both sources have
// landed. We only do this on the first non-loading tick so user toggles
// don't get clobbered by a refetch.
useEffect(() => {
if (picked !== null) return;
if (loadingShares) return;
setPicked(new Set(existing.map((r) => r.userId)));
}, [existing, loadingShares, picked]);
const candidates = useMemo(() => {
const me = user?.id;
return directory.filter((u) => u.id !== me);
}, [directory, user]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return candidates;
return candidates.filter((u) =>
[u.username, u.displayNameAr, u.displayNameEn]
.filter(Boolean)
.some((s) => (s as string).toLowerCase().includes(q)),
);
}, [candidates, search]);
const toggle = (id: number) => {
setPicked((prev) => {
const next = new Set(prev ?? []);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const submit = () => {
if (picked === null) return;
update.mutate(
{ folderId: folder.id, recipientUserIds: Array.from(picked) },
{
onSuccess: () => {
toast({ title: t("notes.folders.shareSaved", "Sharing updated") });
onClose();
},
onError: () => {
toast({
title: t("notes.folders.shareFailed", "Could not update sharing"),
variant: "destructive",
});
},
},
);
};
const isLoading = loadingDir || loadingShares || picked === null;
const count = picked?.size ?? 0;
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md" data-testid="folder-share-dialog">
<DialogHeader className="pr-8">
<DialogTitle>
{t("notes.folders.shareTitle", "Share folder")}
</DialogTitle>
</DialogHeader>
<div className="text-xs text-muted-foreground -mt-1">
<span className="text-foreground/80 font-medium">{folder.name}</span>
{" · "}
{t("notes.folders.shareDescription", "Recipients can view but not edit notes in this folder.")}
</div>
<div className="relative">
<Search
size={14}
className="absolute top-1/2 -translate-y-1/2 text-muted-foreground start-2.5"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("notes.recipientsPlaceholder", "Search people…")}
className="ps-9"
data-testid="folder-share-search"
/>
</div>
<div className="max-h-72 overflow-y-auto border rounded-lg divide-y">
{isLoading && (
<div className="p-3 text-sm text-muted-foreground">
{t("common.loading", "Loading...")}
</div>
)}
{!isLoading && filtered.length === 0 && (
<div className="p-3 text-sm text-muted-foreground text-center"></div>
)}
{!isLoading &&
filtered.map((u) => {
const checked = picked?.has(u.id) ?? false;
return (
<button
key={u.id}
onClick={() => toggle(u.id)}
data-testid={`folder-share-option-${u.id}`}
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-slate-100 ${
checked ? "bg-primary/5" : ""
}`}
>
<span className="truncate">{userDisplayName(u, lang)}</span>
{checked && <Check size={14} className="text-primary" />}
</button>
);
})}
</div>
{count > 0 && (
<div className="text-xs text-muted-foreground">
{t("notes.folders.sharedWithCount", { count })}
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={submit}
disabled={isLoading || update.isPending}
data-testid="folder-share-submit"
>
<Share2 size={14} className="me-1" />
{t("common.save", "Save")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+28
View File
@@ -106,3 +106,31 @@ export type NoteFolder = typeof noteFoldersTable.$inferSelect;
export type NoteRecipient = typeof noteRecipientsTable.$inferSelect;
export type NoteReply = typeof noteRepliesTable.$inferSelect;
/**
* Folder-level live share. The folder owner (`noteFoldersTable.userId`) grants
* a recipient (`recipientUserId`) read-only access to ALL notes currently in
* the folder. Unlike `noteRecipientsTable`, this is NOT a snapshot — recipients
* always see the owner's current notes via a join, so adds/edits/removes by the
* owner are reflected live in the recipient's "Shared with me" view.
*
* Read-only by design: there is no role column. Recipients cannot edit, add,
* move, or delete notes inside a shared folder; they can only read.
*/
export const noteFolderSharesTable = pgTable("note_folder_shares", {
id: serial("id").primaryKey(),
folderId: integer("folder_id")
.notNull()
.references(() => noteFoldersTable.id, { onDelete: "cascade" }),
recipientUserId: integer("recipient_user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
sharedAt: timestamp("shared_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
folderRecipientUnique: uniqueIndex("note_folder_shares_folder_recipient_unique").on(
t.folderId,
t.recipientUserId,
),
}));
export type NoteFolderShare = typeof noteFolderSharesTable.$inferSelect;