Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e369457
docs: spec for permissions and approvals system
winrey May 2, 2026
8b1d1dd
docs: spec for message forwarding (single + multi-select bundle)
winrey May 2, 2026
884a17d
docs(spec): resolve open questions on permissions design
winrey May 2, 2026
9e3fd76
docs: implementation plan for message forwarding
winrey May 2, 2026
a042e40
feat(im): add forward.* i18n strings for en + zh-CN
winrey May 2, 2026
8e52120
feat(db): add im_message_forwards table and 'forward' message type
winrey May 2, 2026
5063664
refactor(im): extract assertWriteAccess into ChannelsService
winrey May 2, 2026
81194e5
feat(im): add ForwardsService with snapshot capture and hydration
winrey May 2, 2026
2c50b1f
feat(im): add forward REST endpoints
winrey May 2, 2026
a8cd388
feat(im): hydrate forward payload on message reads; reject PATCH on f…
winrey May 2, 2026
3968723
test(im): HTTP-integration spec for forward endpoints
winrey May 2, 2026
a4e4df4
feat(client): add forward types, API methods, selection store
winrey May 2, 2026
7a6afb6
feat(client): add ForwardDialog with channel picker and preview
winrey May 2, 2026
6b74801
feat(client): render forwarded message cards and bundle viewer
winrey May 2, 2026
5869067
feat(client): add forward eligibility helper and SelectionActionBar
winrey May 2, 2026
be3aaa4
feat(client): integrate selection mode into MessageList and MessageItem
winrey May 2, 2026
b1a8bd6
fix(client): satisfy i18next strict typing on dynamic forward error key
winrey May 2, 2026
736fcda
feat(client): wire forward+select into hover toolbar and context menu
winrey May 2, 2026
e8caeab
feat(client): dispatch MessageContent to ForwardedMessageCard for typ…
winrey May 2, 2026
4e849fc
docs: mark all message-forwarding tasks complete in .tasks.json
winrey May 4, 2026
50a6e52
fix(forward): apply spec/quality reviewer findings
winrey May 5, 2026
a591756
test(forward) + docs(forward): close test gaps + update CLAUDE.md / R…
winrey May 5, 2026
aaad162
fix(forward): apply Copilot review #101 findings
winrey May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ The backend follows a modular NestJS architecture with two main applications:
- **Auth Module** ([apps/server/apps/gateway/src/auth](apps/server/apps/gateway/src/auth)): JWT-based authentication with Passport strategy, 7-day token expiry
- **IM Module** ([apps/server/apps/gateway/src/im](apps/server/apps/gateway/src/im)): Instant messaging functionality
- Channels: direct, public, private types
- Messages: text, file, image, system, long_text types with threading support (parentId)
- Messages: text, file, image, system, long_text, forward types with threading support (parentId)
- Long text: messages >=20 lines or >=2000 chars auto-classified as `long_text`, truncated at API layer, full content via `GET /messages/:id/full-content`
- Users: profile management, status tracking
- Properties: channel property definitions, message property values (16 types), AI auto-fill
Expand All @@ -102,7 +102,7 @@ The codebase supports Community and Enterprise editions via environment variable

- Uses Drizzle ORM with PostgreSQL
- Schemas organized by domain in [apps/server/libs/database/schemas](apps/server/libs/database/schemas):
- **im/**: users, channels, messages, channel_members, message_attachments, message_reactions, message_acks, mentions, user_channel_read_status, channel_property_definitions, message_properties, audit_logs, channel_views, channel_tabs
- **im/**: users, channels, messages, channel_members, message_attachments, message_reactions, message_acks, mentions, user_channel_read_status, channel_property_definitions, message_properties, audit_logs, channel_views, channel_tabs, message_forwards
- **tenant/**: tenants, tenant_members, workspace_invitations
- **wiki/**: workspace_wikis (Team9 pointer to folder9-backed wikis with permission + approval mode)
- All migrations managed via `pnpm db:migrate`
Expand Down Expand Up @@ -186,6 +186,7 @@ Wiki System:
- Reactions: emoji-based reactions per message
- Properties: structured key-value data per message (16 types), displayed as chips in chat view, powering Table/Board/Calendar views
- Read status: per-user, per-channel tracking via `user_channel_read_status` table
- Forwarding: single-message and multi-message bundle forwarding via `POST /api/v1/im/channels/:id/forward`; original message snapshots stored in `im_message_forwards`; full items fetched via `GET /api/v1/im/messages/:id/forward-items`

### Key Development Patterns

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ _Works great for solo power users too._

💻 **Cross-Platform** — macOS, Windows desktop + Web

💬 **Rich Messaging** — threads, @mentions, reactions, file sharing
💬 **Rich Messaging** — threads, @mentions, reactions, file sharing, message forwarding

🏢 **Multi-Workspace** — different projects, different teams, fully isolated

Expand Down
7 changes: 7 additions & 0 deletions apps/client/src/components/channel/MessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { UserProfileCard } from "./UserProfileCard";
import { CodeBlock } from "./CodeBlock";
import { ImagePreviewDialog } from "./ImagePreviewDialog";
import { LongTextCollapse } from "./LongTextCollapse";
import { ForwardedMessageCard } from "./forward/ForwardedMessageCard";
import { useCreateDirectChannel } from "@/hooks/useChannels";
import { SelectionCopyPopup } from "./SelectionCopyPopup";
import { AstRenderer } from "./AstRenderer";
Expand Down Expand Up @@ -396,6 +397,12 @@ export function MessageContent({
className,
message,
}: MessageContentProps) {
// Forward-type messages bypass the normal content pipeline and render
// a quote/bundle card driven by the hydrated `message.forward` payload.
if (message?.type === "forward") {
return <ForwardedMessageCard message={message} />;
}

// For long_text messages, reactively subscribe to the full-content cache.
// enabled: false means this hook never initiates a fetch — LongTextCollapse
// handles that. But it does subscribe to cache updates, so when the full
Expand Down
36 changes: 35 additions & 1 deletion apps/client/src/components/channel/MessageContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@ import {
ContextMenuShortcut,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { MessageSquare, Link, Copy, Pin, Trash2, Pencil } from "lucide-react";
import {
MessageSquare,
Link,
Copy,
Pin,
Trash2,
Pencil,
Forward,
CheckSquare,
} from "lucide-react";
import type { Message } from "@/types/im";

interface MessageContextMenuProps {
Expand All @@ -21,6 +30,12 @@ interface MessageContextMenuProps {
onPin?: () => void;
onEdit?: () => void;
onDelete?: () => void;
/** Called when Forward menu item is clicked. Only shown when forwardable is true. */
onForward?: () => void;
/** Called when Select menu item is clicked. Only shown when forwardable is true. */
onSelect?: () => void;
/** Controls visibility of Forward + Select menu items. */
forwardable?: boolean;
}

export function MessageContextMenu({
Expand All @@ -34,8 +49,12 @@ export function MessageContextMenu({
onPin,
onEdit,
onDelete,
onForward,
onSelect,
forwardable,
}: MessageContextMenuProps) {
const { t } = useTranslation("message");
const { t: tChannel } = useTranslation("channel");

const handleCopyMessage = () => {
if (message.content) {
Expand Down Expand Up @@ -64,6 +83,21 @@ export function MessageContextMenu({
</ContextMenuItem>
)}

{/* Forward / Select actions */}
{forwardable && onForward && (
<ContextMenuItem onClick={onForward}>
<Forward className="mr-2 h-4 w-4" />
{tChannel("forward.contextMenu.forward")}
<ContextMenuShortcut>F</ContextMenuShortcut>
</ContextMenuItem>
)}
{forwardable && onSelect && (
<ContextMenuItem onClick={onSelect}>
<CheckSquare className="mr-2 h-4 w-4" />
{tChannel("forward.contextMenu.select")}
</ContextMenuItem>
)}

<ContextMenuSeparator />

{/* Copy actions */}
Expand Down
49 changes: 48 additions & 1 deletion apps/client/src/components/channel/MessageHoverToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, type ReactNode } from "react";
import { MessageSquare, SmilePlus } from "lucide-react";
import { MessageSquare, SmilePlus, Forward, CheckSquare } from "lucide-react";
import {
Popover,
PopoverTrigger,
Expand All @@ -24,12 +24,21 @@ interface MessageHoverToolbarProps {
* wrapping a Tags button. When omitted, nothing is rendered for properties.
*/
propertiesSlot?: ReactNode;
/** Called when Forward icon is clicked. Only rendered when forwardable is true. */
onForward?: () => void;
/** Called when Select icon is clicked. Only rendered when forwardable is true. */
onSelect?: () => void;
/** Controls visibility of Forward + Select buttons. */
forwardable?: boolean;
}

export function MessageHoverToolbar({
onReaction,
onReplyInThread,
propertiesSlot,
onForward,
onSelect,
forwardable,
}: MessageHoverToolbarProps) {
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);

Expand Down Expand Up @@ -111,6 +120,44 @@ export function MessageHoverToolbar({
</Tooltip>
</>
)}

{forwardable && (onForward || onSelect) && (
<div className="w-px h-4 bg-border mx-0.5" />
)}

{forwardable && onForward && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onForward}
aria-label="Forward"
className="flex items-center justify-center w-7 h-7 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<Forward size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Forward
</TooltipContent>
</Tooltip>
)}

{forwardable && onSelect && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onSelect}
aria-label="Select"
className="flex items-center justify-center w-7 h-7 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<CheckSquare size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Select
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
);
Expand Down
Loading
Loading