Skip to content

Commit 3359f49

Browse files
committed
Add edit, delete and emojis on messages
1 parent 4b122bd commit 3359f49

File tree

20 files changed

+928
-160
lines changed

20 files changed

+928
-160
lines changed

backend/api/src/app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ import {localSendTestEmail} from "api/test";
6969
import path from "node:path";
7070
import {saveSubscriptionMobile} from "api/save-subscription-mobile";
7171
import {IS_LOCAL} from "common/hosting/constants";
72+
import {editMessage} from "api/edit-message";
73+
import {reactToMessage} from "api/react-to-message";
74+
import {deleteMessage} from "api/delete-message";
7275

7376
// const corsOptions: CorsOptions = {
7477
// origin: ['*'], // Only allow requests from this domain
@@ -358,6 +361,9 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
358361
'save-subscription-mobile': saveSubscriptionMobile,
359362
'create-bookmarked-search': createBookmarkedSearch,
360363
'delete-bookmarked-search': deleteBookmarkedSearch,
364+
'delete-message': deleteMessage,
365+
'edit-message': editMessage,
366+
'react-to-message': reactToMessage,
361367
// 'auth-google': authGoogle,
362368
}
363369

backend/api/src/delete-message.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {APIError, APIHandler} from './helpers/endpoint'
2+
import {createSupabaseDirectClient} from 'shared/supabase/init'
3+
import {broadcastPrivateMessages} from "api/helpers/private-messages";
4+
5+
// const DELETED_MESSAGE_CONTENT: JSONContent = {
6+
// type: 'doc',
7+
// content: [
8+
// {
9+
// type: 'paragraph',
10+
// content: [
11+
// {
12+
// type: 'text',
13+
// text: '[deleted]',
14+
// },
15+
// ],
16+
// },
17+
// ],
18+
// }
19+
20+
export const deleteMessage: APIHandler<'delete-message'> = async ({messageId}, auth) => {
21+
const pg = createSupabaseDirectClient()
22+
23+
// Verify user is the message author and message is not too old
24+
const message = await pg.oneOrNone(
25+
`SELECT *
26+
FROM private_user_messages
27+
WHERE id = $1
28+
AND user_id = $2`,
29+
[messageId, auth.uid]
30+
)
31+
32+
if (!message) {
33+
throw new APIError(404, 'Message not found')
34+
}
35+
36+
// Soft delete the message
37+
// await pg.none(
38+
// `UPDATE private_user_messages
39+
// SET deleted = TRUE,
40+
// content = $2::jsonb,
41+
// ciphertext = NULL,
42+
// iv = NULL,
43+
// tag = NULL
44+
// WHERE id = $1`,
45+
// [messageId, DELETED_MESSAGE_CONTENT]
46+
// )
47+
48+
// Hard delete the message
49+
await pg.none(
50+
`DELETE
51+
FROM private_user_messages
52+
WHERE id = $1
53+
AND user_id = $2`,
54+
[messageId, auth.uid]
55+
)
56+
57+
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
58+
.catch((err) => {
59+
console.error('broadcastPrivateMessages failed', err)
60+
})
61+
62+
return {success: true}
63+
}
64+

backend/api/src/edit-message.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {APIError, APIHandler} from './helpers/endpoint'
2+
import {createSupabaseDirectClient} from 'shared/supabase/init'
3+
import {encryptMessage} from "shared/encryption";
4+
import {broadcastPrivateMessages} from "api/helpers/private-messages";
5+
6+
7+
export const editMessage: APIHandler<'edit-message'> = async ({messageId, content}, auth) => {
8+
const pg = createSupabaseDirectClient()
9+
10+
// Verify user is the message author and message is not too old
11+
const message = await pg.oneOrNone(
12+
`SELECT *
13+
FROM private_user_messages
14+
WHERE id = $1
15+
AND user_id = $2
16+
AND created_time > NOW() - INTERVAL '1 day'
17+
AND deleted = FALSE`,
18+
[messageId, auth.uid]
19+
)
20+
21+
if (!message) {
22+
throw new APIError(404, 'Message not found or cannot be edited')
23+
}
24+
25+
const plaintext = JSON.stringify(content)
26+
const {ciphertext, iv, tag} = encryptMessage(plaintext)
27+
await pg.none(
28+
`UPDATE private_user_messages
29+
SET ciphertext = $1,
30+
iv = $2,
31+
tag = $3,
32+
is_edited = TRUE,
33+
edited_at = NOW()
34+
WHERE id = $4`,
35+
[ciphertext, iv, tag, messageId]
36+
)
37+
38+
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
39+
.catch((err) => {
40+
console.error('broadcastPrivateMessages failed', err)
41+
})
42+
43+
return {success: true}
44+
}

backend/api/src/helpers/private-messages.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,27 @@ export const addUsersToPrivateMessageChannel = async (
9090
)
9191
}
9292

93+
export async function broadcastPrivateMessages(
94+
pg: SupabaseDirectClient,
95+
channelId: number,
96+
userId: string,
97+
) {
98+
const otherUserIds = await pg.map<string>(
99+
`select user_id
100+
from private_user_message_channel_members
101+
where channel_id = $1
102+
and user_id != $2
103+
and status != 'left'
104+
`,
105+
[channelId, userId],
106+
(r) => r.user_id
107+
)
108+
otherUserIds.concat(userId).forEach((otherUserId) => {
109+
broadcast(`private-user-messages/${otherUserId}`, {})
110+
})
111+
return otherUserIds;
112+
}
113+
93114
export const createPrivateUserMessageMain = async (
94115
creator: User,
95116
channelId: number,
@@ -117,20 +138,7 @@ export const createPrivateUserMessageMain = async (
117138
channel_id: channelId,
118139
user_id: creator.id,
119140
}
120-
121-
const otherUserIds = await pg.map<string>(
122-
`select user_id
123-
from private_user_message_channel_members
124-
where channel_id = $1
125-
and user_id != $2
126-
and status != 'left'
127-
`,
128-
[channelId, creator.id],
129-
(r) => r.user_id
130-
)
131-
otherUserIds.concat(creator.id).forEach((otherUserId) => {
132-
broadcast(`private-user-messages/${otherUserId}`, {})
133-
})
141+
const otherUserIds = await broadcastPrivateMessages(pg, channelId, creator.id);
134142

135143
// Fire and forget safely
136144
void notifyOtherUserInChannelIfInactive(channelId, creator, content, pg)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {APIError, APIHandler} from './helpers/endpoint'
2+
import {createSupabaseDirectClient} from 'shared/supabase/init'
3+
import {broadcastPrivateMessages} from "api/helpers/private-messages";
4+
5+
6+
export const reactToMessage: APIHandler<'react-to-message'> = async ({messageId, reaction, toDelete}, auth) => {
7+
const pg = createSupabaseDirectClient()
8+
9+
// Verify user is a member of the channel
10+
const message = await pg.oneOrNone(
11+
`SELECT *
12+
FROM private_user_message_channel_members m
13+
JOIN private_user_messages msg ON msg.channel_id = m.channel_id
14+
WHERE m.user_id = $1
15+
AND msg.id = $2`,
16+
[auth.uid, messageId]
17+
)
18+
19+
if (!message) {
20+
throw new APIError(403, 'Not authorized to react to this message')
21+
}
22+
23+
if (toDelete) {
24+
// Remove the reaction
25+
await pg.none(
26+
`UPDATE private_user_messages
27+
SET reactions = reactions - $1
28+
WHERE id = $2
29+
AND reactions -> $1 ? $3`,
30+
[reaction, messageId, auth.uid]
31+
)
32+
} else {
33+
// Toggle reaction
34+
await pg.none(
35+
`UPDATE private_user_messages
36+
SET reactions =
37+
CASE
38+
WHEN reactions -> $1 IS NOT NULL
39+
THEN reactions - $1
40+
ELSE jsonb_set(
41+
COALESCE(reactions, '{}'::jsonb),
42+
ARRAY [$1],
43+
(
44+
COALESCE(reactions -> $1, '[]'::jsonb) || to_jsonb($2::text)
45+
),
46+
TRUE
47+
)
48+
END
49+
WHERE id = $3`,
50+
[reaction, auth.uid, messageId]
51+
)
52+
}
53+
54+
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
55+
.catch((err) => {
56+
console.error('broadcastPrivateMessages failed', err)
57+
})
58+
59+
return {success: true}
60+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
-- Add columns to support message actions
2+
ALTER TABLE private_user_messages
3+
ADD COLUMN IF NOT EXISTS is_edited BOOLEAN DEFAULT FALSE,
4+
ADD COLUMN IF NOT EXISTS reactions JSONB DEFAULT '{}'::jsonb,
5+
ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE,
6+
ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
7+
8+
-- Create a function to update edited_at timestamp
9+
-- CREATE OR REPLACE FUNCTION update_edited_at()
10+
-- RETURNS TRIGGER AS $$
11+
-- BEGIN
12+
-- IF NEW.content <> OLD.content THEN
13+
-- NEW.is_edited := TRUE;
14+
-- NEW.edited_at := NOW();
15+
-- END IF;
16+
-- RETURN NEW;
17+
-- END;
18+
-- $$ LANGUAGE plpgsql;
19+
--
20+
-- -- Create a trigger to update edited_at when content changes
21+
-- DROP TRIGGER IF EXISTS update_private_message_edited_at ON private_user_messages;
22+
-- CREATE TRIGGER update_private_message_edited_at
23+
-- BEFORE UPDATE ON private_user_messages
24+
-- FOR EACH ROW
25+
-- WHEN (OLD.content IS DISTINCT FROM NEW.content)
26+
-- EXECUTE FUNCTION update_edited_at();
27+
28+
-- Update RLS policies to allow message owners to update their messages
29+
-- DROP POLICY IF EXISTS "private message update" ON private_user_messages;
30+
-- CREATE POLICY "private message update" ON private_user_messages
31+
-- FOR UPDATE USING (
32+
-- user_id = firebase_uid()
33+
-- AND created_time > NOW() - INTERVAL '1 day' -- Only allow editing for 24 hours
34+
-- AND deleted = FALSE
35+
-- );
36+
37+
-- Add policy for soft delete
38+
-- DROP POLICY IF EXISTS "private message delete" ON private_user_messages;
39+
-- CREATE POLICY "private message delete" ON private_user_messages
40+
-- FOR UPDATE USING (
41+
-- user_id = firebase_uid()
42+
-- );
43+
44+
-- Add policy for reactions
45+
-- DROP POLICY IF EXISTS "private message react" ON private_user_messages;
46+
-- CREATE POLICY "private message react" ON private_user_messages
47+
-- FOR UPDATE USING (
48+
-- EXISTS (
49+
-- SELECT 1
50+
-- FROM private_user_message_channels ch
51+
-- JOIN private_user_message_channel_members m ON ch.id = m.channel_id
52+
-- WHERE m.user_id = firebase_uid()
53+
-- AND ch.id = private_user_messages.channel_id
54+
-- )
55+
-- );

common/src/api/schema.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,55 @@ export const API = (_apiTypeCheck = {
574574
summary: 'Leave a private message channel',
575575
tag: 'Messages',
576576
},
577+
'edit-message': {
578+
method: 'POST',
579+
authed: true,
580+
rateLimited: true,
581+
returns: {} as any,
582+
props: z.object({
583+
messageId: z.number(),
584+
content: contentSchema,
585+
}),
586+
summary: 'Edit a private message',
587+
tag: 'Messages',
588+
},
589+
'delete-message': {
590+
method: 'POST',
591+
authed: true,
592+
rateLimited: true,
593+
returns: {} as any,
594+
props: z.object({
595+
messageId: z.number(),
596+
}),
597+
summary: 'Delete a private message',
598+
tag: 'Messages',
599+
},
600+
'react-to-message': {
601+
method: 'POST',
602+
authed: true,
603+
rateLimited: true,
604+
returns: {} as any,
605+
props: z.object({
606+
messageId: z.number(),
607+
reaction: z.string(),
608+
toDelete: z.boolean().optional(),
609+
}),
610+
summary: 'Add or remove a reaction to a message',
611+
tag: 'Messages',
612+
},
613+
// 'get-message-reactions': {
614+
// method: 'GET',
615+
// authed: true,
616+
// rateLimited: false,
617+
// returns: {} as {
618+
// reactions: Record<string, number>
619+
// },
620+
// props: z.object({
621+
// messageId: z.string(),
622+
// }),
623+
// summary: 'Get reactions for a message',
624+
// tag: 'Messages',
625+
// },
577626
'create-compatibility-question': {
578627
method: 'POST',
579628
authed: true,

common/src/chat-message.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { type JSONContent } from '@tiptap/core'
22
export type ChatVisibility = 'private' | 'system_status' | 'introduction'
33

44
export type ChatMessage = {
5-
id: string
5+
id: number
66
userId: string
77
channelId: string
88
content: JSONContent
99
createdTime: number
1010
visibility: ChatVisibility
11+
isEdited: boolean
12+
reactions: any
1113
}
1214
export type PrivateChatMessage = Omit<ChatMessage, 'id'> & {
1315
id: number

0 commit comments

Comments
 (0)