fix: DM close visibility flag, wire Remove Friend button, harden deploy script
- Redesign DM close as a visibility flag (closed column) instead of row deletion, so message broadcasts still reach users who closed a DM conversation - Add broadcastDmMessage() helper that auto-resurfaces closed DMs when new messages arrive - Wire Remove Friend button in DM welcome header with onClick handler and friend check - Fix deploy.sh to cd to its own directory so rsync always runs from project root
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Configuration
|
||||
PI_IP="192.168.1.10"
|
||||
|
||||
@@ -35,6 +35,12 @@ export function runMigrations(db: Database.Database): void {
|
||||
columns: [
|
||||
{ name: 'dm_message_id', type: 'TEXT' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'dm_members',
|
||||
columns: [
|
||||
{ name: 'closed', type: 'INTEGER DEFAULT 0' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ export const dmChannels = sqliteTable('dm_channels', {
|
||||
export const dmMembers = sqliteTable('dm_members', {
|
||||
dmChannelId: text('dm_channel_id').notNull().references(() => dmChannels.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
closed: integer('closed').default(0),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.dmChannelId, table.userId] }),
|
||||
}));
|
||||
|
||||
@@ -27,6 +27,65 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a DM message to all members of a DM channel.
|
||||
* For members who have closed the channel (closed=1), also sends a
|
||||
* dm_channel_created event to resurface the channel in their sidebar,
|
||||
* and flips their closed flag back to 0.
|
||||
*/
|
||||
export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUser): void {
|
||||
const db = getDb();
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
||||
.all();
|
||||
|
||||
for (const member of dmMembers) {
|
||||
// If this member had closed the DM, resurface it first
|
||||
if (member.closed === 1) {
|
||||
db.update(schema.dmMembers)
|
||||
.set({ closed: 0 })
|
||||
.where(and(
|
||||
eq(schema.dmMembers.dmChannelId, dmChannelId),
|
||||
eq(schema.dmMembers.userId, member.userId),
|
||||
))
|
||||
.run();
|
||||
|
||||
// Build and send dm_channel_created so their sidebar picks it up
|
||||
const allMemberRows = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
||||
.all();
|
||||
const memberUserIds = allMemberRows.map(m => m.userId);
|
||||
const users = memberUserIds.length > 0
|
||||
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
|
||||
: [];
|
||||
|
||||
const dmChannel = db.select()
|
||||
.from(schema.dmChannels)
|
||||
.where(eq(schema.dmChannels.id, dmChannelId))
|
||||
.get();
|
||||
|
||||
if (dmChannel) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_channel_created',
|
||||
dmChannel: {
|
||||
id: dmChannel.id,
|
||||
createdAt: dmChannel.createdAt,
|
||||
members: users.map(sanitizeUser),
|
||||
lastMessage: message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_message_created',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/dm - List user's DM channels
|
||||
app.get('/api/dm', {
|
||||
@@ -36,7 +95,10 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const memberships = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.userId, request.userId))
|
||||
.where(and(
|
||||
eq(schema.dmMembers.userId, request.userId),
|
||||
eq(schema.dmMembers.closed, 0),
|
||||
))
|
||||
.all();
|
||||
|
||||
const dmChannels: DmChannel[] = [];
|
||||
@@ -115,7 +177,8 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
// Check if DM channel already exists between these two users (both are members)
|
||||
// Check if DM channel already exists between these two users
|
||||
// (both have membership rows, regardless of closed state)
|
||||
const myDms = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.userId, request.userId))
|
||||
@@ -131,7 +194,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
.get();
|
||||
|
||||
if (otherMember) {
|
||||
// DM channel already exists, both users are members
|
||||
// DM channel already exists between these users
|
||||
const dmChannel = db.select()
|
||||
.from(schema.dmChannels)
|
||||
.where(eq(schema.dmChannels.id, myDm.dmChannelId))
|
||||
@@ -139,6 +202,17 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
if (!dmChannel) continue;
|
||||
|
||||
// Reopen if the requesting user had closed it
|
||||
if (myDm.closed === 1) {
|
||||
db.update(schema.dmMembers)
|
||||
.set({ closed: 0 })
|
||||
.where(and(
|
||||
eq(schema.dmMembers.dmChannelId, myDm.dmChannelId),
|
||||
eq(schema.dmMembers.userId, request.userId),
|
||||
))
|
||||
.run();
|
||||
}
|
||||
|
||||
const dmMemberRows = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, myDm.dmChannelId))
|
||||
@@ -173,79 +247,6 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the other user has a DM channel with us that we left (closed)
|
||||
// If so, re-add ourselves to it instead of creating a new one
|
||||
const theirDms = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.userId, userId))
|
||||
.all();
|
||||
|
||||
for (const theirDm of theirDms) {
|
||||
// Check if any messages exist between us on this channel (indicates a previous DM)
|
||||
const dmChannel = db.select()
|
||||
.from(schema.dmChannels)
|
||||
.where(eq(schema.dmChannels.id, theirDm.dmChannelId))
|
||||
.get();
|
||||
|
||||
if (!dmChannel) continue;
|
||||
|
||||
// Check total members — a DM channel should only have the other user if we left
|
||||
const allMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, theirDm.dmChannelId))
|
||||
.all();
|
||||
|
||||
// If it's a 1-member channel (just them) or we see past messages from us, re-join
|
||||
const onlyOtherUser = allMembers.length === 1 && allMembers[0]!.userId === userId;
|
||||
if (onlyOtherUser) {
|
||||
// Check if there are any messages from us in this channel (confirms it was our DM)
|
||||
const ourOldMessages = db.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(and(
|
||||
eq(schema.dmMessages.dmChannelId, theirDm.dmChannelId),
|
||||
eq(schema.dmMessages.userId, request.userId),
|
||||
))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (ourOldMessages.length > 0) {
|
||||
// Re-add ourselves to this existing DM channel
|
||||
db.insert(schema.dmMembers).values({
|
||||
dmChannelId: theirDm.dmChannelId,
|
||||
userId: request.userId,
|
||||
}).run();
|
||||
|
||||
const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
const members = [currentUserRow, targetUser]
|
||||
.filter((u): u is NonNullable<typeof u> => u !== undefined)
|
||||
.map(sanitizeUser);
|
||||
|
||||
const lastMsgRows = db.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.dmChannelId, theirDm.dmChannelId))
|
||||
.orderBy(desc(schema.dmMessages.createdAt))
|
||||
.limit(1)
|
||||
.all();
|
||||
const lastMsg = lastMsgRows[0] ?? null;
|
||||
|
||||
const result: DmChannel = {
|
||||
id: dmChannel.id,
|
||||
createdAt: dmChannel.createdAt,
|
||||
members,
|
||||
lastMessage: lastMsg ? {
|
||||
id: lastMsg.id,
|
||||
dmChannelId: lastMsg.dmChannelId,
|
||||
userId: lastMsg.userId,
|
||||
content: lastMsg.content,
|
||||
createdAt: lastMsg.createdAt,
|
||||
} : null,
|
||||
};
|
||||
|
||||
return reply.code(200).send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new DM channel
|
||||
const dmChannelId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
@@ -306,8 +307,9 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
|
||||
}
|
||||
|
||||
// Soft close: remove the user's membership row (Discord-style hide)
|
||||
db.delete(schema.dmMembers)
|
||||
// Soft close: set closed flag (preserves membership for future message delivery)
|
||||
db.update(schema.dmMembers)
|
||||
.set({ closed: 1 })
|
||||
.where(and(
|
||||
eq(schema.dmMembers.dmChannelId, id),
|
||||
eq(schema.dmMembers.userId, request.userId),
|
||||
@@ -428,18 +430,8 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
user: sanitizeUser(user),
|
||||
};
|
||||
|
||||
// Broadcast via WebSocket to all DM members
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, id))
|
||||
.all();
|
||||
|
||||
for (const member of dmMembers) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_message_created',
|
||||
message,
|
||||
});
|
||||
}
|
||||
// Broadcast to all DM members (including those who closed the channel)
|
||||
broadcastDmMessage(id, message);
|
||||
|
||||
return reply.code(201).send(message);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getDb, schema } from '../db/index.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { connectionManager } from './handler.js';
|
||||
import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js';
|
||||
import { broadcastDmMessage } from '../routes/dm.js';
|
||||
import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared';
|
||||
|
||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
@@ -497,18 +498,8 @@ function handleDmMessageCreate(event: Record<string, unknown>, userId: string):
|
||||
user: sanitizeUser(user),
|
||||
};
|
||||
|
||||
// Send to all DM members
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
||||
.all();
|
||||
|
||||
for (const member of dmMembers) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_message_created',
|
||||
message: dmMessage,
|
||||
});
|
||||
}
|
||||
// Broadcast to all DM members (including those who closed the channel)
|
||||
broadcastDmMessage(dmChannelId, dmMessage);
|
||||
}
|
||||
|
||||
function handleDmTypingStart(event: Record<string, unknown>, userId: string, username: string): void {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Message } from './Message';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useServerStore, isDmChannel } from '../../stores/serverStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
const EMPTY_MESSAGES = [];
|
||||
@@ -100,13 +101,16 @@ export function MessageList({ channelId }) {
|
||||
function WelcomeHeader({ channelId }) {
|
||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const removeFriend = useSocialStore((s) => s.removeFriend);
|
||||
const friends = useSocialStore((s) => s.friends);
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
const dm = dmChannels.find(d => d.id === channelId);
|
||||
const otherUser = dm?.members.find(m => m.id !== authUser?.id);
|
||||
const displayName = otherUser?.displayName ?? otherUser?.username ?? 'Unknown';
|
||||
const username = otherUser?.username ?? 'unknown';
|
||||
return (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "mb-2", children: _jsx(Avatar, { src: otherUser?.avatar, name: displayName, size: 80 }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: displayName }), _jsxs("p", { className: "text-discord-text-secondary text-[14px] mt-1", children: ["This is the beginning of your direct message history with ", _jsxs("strong", { children: ["@", username] }), "."] }), _jsx("div", { className: "mt-4", children: _jsx("button", { className: "px-4 py-1.5 bg-discord-bg-accent hover:bg-discord-bg-surface-higher text-[14px] font-medium text-discord-text-primary rounded-[3px] transition-colors", children: "Remove Friend" }) }), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] }));
|
||||
const isFriend = otherUser ? friends.some(f => f.id === otherUser.id) : false;
|
||||
return (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "mb-2", children: _jsx(Avatar, { src: otherUser?.avatar, name: displayName, size: 80 }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: displayName }), _jsxs("p", { className: "text-discord-text-secondary text-[14px] mt-1", children: ["This is the beginning of your direct message history with ", _jsxs("strong", { children: ["@", username] }), "."] }), isFriend && otherUser && (_jsx("div", { className: "mt-4", children: _jsx("button", { onClick: () => removeFriend(otherUser.id), className: "px-4 py-1.5 bg-discord-bg-accent hover:bg-discord-bg-surface-higher text-[14px] font-medium text-discord-text-primary rounded-[3px] transition-colors", children: "Remove Friend" }) })), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] }));
|
||||
}
|
||||
return (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "w-[68px] h-[68px] rounded-full bg-discord-bg-accent flex items-center justify-center mb-4 text-white", children: _jsx("svg", { width: "42", height: "42", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: "Welcome to the channel!" }), _jsx("p", { className: "text-discord-text-secondary text-[16px] mt-2", children: "This is the start of the conversation." }), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] }));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Message } from './Message';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useServerStore, isDmChannel } from '../../stores/serverStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import type { MessageWithUser } from '@opencord/shared';
|
||||
@@ -158,6 +159,8 @@ export function MessageList({ channelId }: MessageListProps) {
|
||||
function WelcomeHeader({ channelId }: { channelId: string }) {
|
||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const removeFriend = useSocialStore((s) => s.removeFriend);
|
||||
const friends = useSocialStore((s) => s.friends);
|
||||
const isDm = isDmChannel(channelId);
|
||||
|
||||
if (isDm) {
|
||||
@@ -165,6 +168,7 @@ function WelcomeHeader({ channelId }: { channelId: string }) {
|
||||
const otherUser = dm?.members.find(m => m.id !== authUser?.id);
|
||||
const displayName = otherUser?.displayName ?? otherUser?.username ?? 'Unknown';
|
||||
const username = otherUser?.username ?? 'unknown';
|
||||
const isFriend = otherUser ? friends.some(f => f.id === otherUser.id) : false;
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-8 pb-4">
|
||||
@@ -175,11 +179,16 @@ function WelcomeHeader({ channelId }: { channelId: string }) {
|
||||
<p className="text-discord-text-secondary text-[14px] mt-1">
|
||||
This is the beginning of your direct message history with <strong>@{username}</strong>.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<button className="px-4 py-1.5 bg-discord-bg-accent hover:bg-discord-bg-surface-higher text-[14px] font-medium text-discord-text-primary rounded-[3px] transition-colors">
|
||||
Remove Friend
|
||||
</button>
|
||||
</div>
|
||||
{isFriend && otherUser && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => removeFriend(otherUser.id)}
|
||||
className="px-4 py-1.5 bg-discord-bg-accent hover:bg-discord-bg-surface-higher text-[14px] font-medium text-discord-text-primary rounded-[3px] transition-colors"
|
||||
>
|
||||
Remove Friend
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 border-b border-discord-modifier-accent" />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user