From 19b7be4d15e2bfd82383f0dea27e9c84a7f55875 Mon Sep 17 00:00:00 2001 From: Sam Fitzgerald Date: Sun, 14 Jun 2026 21:53:12 -0400 Subject: [PATCH 1/9] feat(parity): migration 003 - presence/status, unread RPC, notification triggers, link previews Phase 1 of Tier 0 Slack parity: backend schema + types. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/features/chat/chat-workspace.tsx | 6 +- src/types/chat.ts | 15 +++ src/types/database.ts | 16 +++ supabase/migrations/003_tier0_parity.sql | 152 +++++++++++++++++++++++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/003_tier0_parity.sql diff --git a/src/features/chat/chat-workspace.tsx b/src/features/chat/chat-workspace.tsx index e004ccf..9ffb537 100644 --- a/src/features/chat/chat-workspace.tsx +++ b/src/features/chat/chat-workspace.tsx @@ -151,16 +151,18 @@ export function ChatWorkspace() { return; } + const profileColumns = + "id,org_id,email,display_name,avatar_url,status,status_emoji,status_text,status_expires_at,presence,role,last_seen_at"; const { data: profileData } = await supabase .from("profiles") - .select("id,org_id,email,display_name,avatar_url,status,role,last_seen_at") + .select(profileColumns) .eq("id", authUser.id) .single(); const typedProfile = profileData as unknown as Profile | null; const { data: memberData } = typedProfile ? await supabase .from("profiles") - .select("id,org_id,email,display_name,avatar_url,status,role,last_seen_at") + .select(profileColumns) .eq("org_id", typedProfile.org_id) .order("display_name", { ascending: true }) : { data: [] }; diff --git a/src/types/chat.ts b/src/types/chat.ts index 2804431..67190b0 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -1,5 +1,7 @@ export type ChannelType = "public" | "private" | "dm"; +export type PresenceState = "active" | "away" | "dnd"; + export type Profile = { id: string; org_id: string; @@ -7,10 +9,23 @@ export type Profile = { display_name: string | null; avatar_url: string | null; status: string | null; + status_emoji: string | null; + status_text: string | null; + status_expires_at: string | null; + presence: PresenceState; role: "admin" | "member"; last_seen_at: string | null; }; +export type AppNotification = { + id: string; + user_id: string; + type: "mention" | "thread" | "dm" | "reaction"; + message_id: string | null; + read_at: string | null; + created_at: string; +}; + export type Channel = { id: string; org_id: string; diff --git a/src/types/database.ts b/src/types/database.ts index a6f490f..cdeec36 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -22,6 +22,10 @@ export type Database = { display_name: string | null; avatar_url: string | null; status: string | null; + status_emoji: string | null; + status_text: string | null; + status_expires_at: string | null; + presence: "active" | "away" | "dnd"; role: "admin" | "member"; last_seen_at: string | null; created_at: string; @@ -93,6 +97,14 @@ export type Database = { revoked_at: string | null; created_at: string; }>; + link_previews: Table<{ + url: string; + title: string | null; + description: string | null; + image_url: string | null; + site_name: string | null; + fetched_at: string; + }>; }; Views: Record; Functions: { @@ -100,6 +112,10 @@ export type Database = { Args: { invite_token: string }; Returns: string; }; + channel_unread_counts: { + Args: Record; + Returns: Array<{ channel_id: string; unread: number }>; + }; create_invite: { Args: { invite_email: string; invite_role?: "admin" | "member" }; Returns: Array<{ id: string; token: string; expires_at: string }>; diff --git a/supabase/migrations/003_tier0_parity.sql b/supabase/migrations/003_tier0_parity.sql new file mode 100644 index 0000000..ede1c13 --- /dev/null +++ b/supabase/migrations/003_tier0_parity.sql @@ -0,0 +1,152 @@ +-- Tier 0 Slack-parity: presence/status, unread counts, notification wiring, link previews. + +-- Presence + custom status on profiles +create type public.presence_state as enum ('active', 'away', 'dnd'); + +alter table public.profiles + add column if not exists status_emoji text, + add column if not exists status_text text, + add column if not exists status_expires_at timestamptz, + add column if not exists presence public.presence_state not null default 'active'; + +-- Per-channel unread counts for the current user +create or replace function public.channel_unread_counts() +returns table (channel_id uuid, unread integer) +language sql +stable +security definer +set search_path = public +as $$ + select cm.channel_id, count(m.id)::int as unread + from public.channel_members cm + left join public.messages m + on m.channel_id = cm.channel_id + and m.parent_id is null + and m.deleted_at is null + and m.author_id <> cm.user_id + and (cm.last_read_at is null or m.created_at > cm.last_read_at) + where cm.user_id = auth.uid() + group by cm.channel_id +$$; + +-- Thread reply notifications: notify the parent author +create or replace function public.create_thread_notifications() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +declare + parent_author uuid; +begin + if new.parent_id is not null then + select author_id into parent_author from public.messages where id = new.parent_id; + if parent_author is not null and parent_author <> new.author_id then + insert into public.notifications (user_id, type, message_id) + values (parent_author, 'thread', new.id); + end if; + end if; + return new; +end; +$$; + +create trigger messages_thread_notify +after insert on public.messages +for each row execute function public.create_thread_notifications(); + +-- DM notifications: notify the other member(s) of a dm channel +create or replace function public.create_dm_notifications() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + insert into public.notifications (user_id, type, message_id) + select cm.user_id, 'dm', new.id + from public.channel_members cm + join public.channels c on c.id = cm.channel_id + where cm.channel_id = new.channel_id + and c.type = 'dm' + and cm.user_id <> new.author_id; + return new; +end; +$$; + +create trigger messages_dm_notify +after insert on public.messages +for each row execute function public.create_dm_notifications(); + +-- Reaction notifications: notify the message author +create or replace function public.create_reaction_notifications() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +declare + msg_author uuid; +begin + select author_id into msg_author from public.messages where id = new.message_id; + if msg_author is not null and msg_author <> new.user_id then + insert into public.notifications (user_id, type, message_id) + values (msg_author, 'reaction', new.message_id); + end if; + return new; +end; +$$; + +create trigger reactions_notify +after insert on public.reactions +for each row execute function public.create_reaction_notifications(); + +-- Broadcast new notifications to the recipient's private realtime topic +create or replace function public.broadcast_notification() +returns trigger +language plpgsql +security definer +set search_path = public, realtime +as $$ +begin + perform realtime.broadcast_changes( + 'user:' || new.user_id::text, + TG_OP, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA, new, null + ); + return new; +end; +$$; + +create trigger notifications_broadcast +after insert on public.notifications +for each row execute function public.broadcast_notification(); + +-- Allow a user to subscribe to their own private realtime topic +create policy "user realtime is self gated" +on realtime.messages +for select +to authenticated +using ( + split_part(realtime.topic(), ':', 1) = 'user' + and split_part(realtime.topic(), ':', 2) = auth.uid()::text +); + +-- Cached link previews (unfurling) +create table if not exists public.link_previews ( + url text primary key, + title text, + description text, + image_url text, + site_name text, + fetched_at timestamptz not null default now() +); + +alter table public.link_previews enable row level security; + +create policy "authenticated read link previews" on public.link_previews +for select to authenticated using (true); + +create policy "authenticated insert link previews" on public.link_previews +for insert to authenticated with check (true); + +create policy "authenticated update link previews" on public.link_previews +for update to authenticated using (true) with check (true); From ecf15a41729959c6c32c37077ad001b26a2a2d8d Mon Sep 17 00:00:00 2001 From: Sam Fitzgerald Date: Sun, 14 Jun 2026 22:00:12 -0400 Subject: [PATCH 2/9] feat(parity): message edit + delete with realtime hook extraction (Phase 2) Inline edit (Esc/Cmd+Enter), soft-delete, (edited) indicator. Extract realtime subscription into useChannelRealtime; split MessageRow into MessageActions/MessageEditor to satisfy lint limits. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/features/chat/chat-parts.tsx | 151 +++++++++++++++++++--- src/features/chat/chat-workspace.tsx | 131 +++++-------------- src/features/chat/use-channel-realtime.ts | 129 ++++++++++++++++++ 3 files changed, 295 insertions(+), 116 deletions(-) create mode 100644 src/features/chat/use-channel-realtime.ts diff --git a/src/features/chat/chat-parts.tsx b/src/features/chat/chat-parts.tsx index 30d2274..5ccbc17 100644 --- a/src/features/chat/chat-parts.tsx +++ b/src/features/chat/chat-parts.tsx @@ -1,7 +1,7 @@ "use client"; -import type { ReactNode } from "react"; -import { Hash, Lock, MessageSquare, Paperclip, Plus, Search, Send } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { Hash, Lock, MessageSquare, Paperclip, Pencil, Plus, Search, Send, Trash2 } from "lucide-react"; import type { User } from "@supabase/supabase-js"; import { Button } from "@/components/ui/button"; import { Input, Textarea } from "@/components/ui/input"; @@ -227,6 +227,8 @@ export function ChannelBody({ currentUserId, onReact, onThread, + onEdit, + onDelete, onDraft, onPrepareChannel, inviteEmail, @@ -239,6 +241,8 @@ export function ChannelBody({ currentUserId?: string; onReact: (message: ChatMessage, emoji: string) => void; onThread: (message: ChatMessage) => void; + onEdit?: (message: ChatMessage, nextBody: string) => void; + onDelete?: (message: ChatMessage) => void; onDraft: () => void; onPrepareChannel: () => void; inviteEmail: string; @@ -266,6 +270,8 @@ export function ChannelBody({ currentUserId={currentUserId} onReact={onReact} onThread={onThread} + onEdit={onEdit} + onDelete={onDelete} /> ))} @@ -328,23 +334,123 @@ export function SearchOverlay({ ); } +function MessageActions({ + message, + isOwn, + onReact, + onThread, + onEdit, + onDelete +}: { + message: ChatMessage; + isOwn: boolean; + onReact: (message: ChatMessage, emoji: string) => void; + onThread: (message: ChatMessage) => void; + onEdit?: () => void; + onDelete?: (message: ChatMessage) => void; +}) { + return ( +
+ {emojiQuick.map((emoji) => ( + + ))} + + {isOwn && onEdit ? ( + + ) : null} + {isOwn && onDelete ? ( + + ) : null} +
+ ); +} + +function MessageEditor({ + initial, + onSave, + onCancel +}: { + initial: string; + onSave: (value: string) => void; + onCancel: () => void; +}) { + const [draft, setDraft] = useState(initial); + const save = () => { + if (draft.trim()) onSave(draft); + }; + + return ( +
+