Skip to content

Commit e053305

Browse files
Jack Coweycursoragent
andcommitted
Prepare release 0.1.11
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f12e5eb commit e053305

13 files changed

Lines changed: 6460 additions & 27 deletions

README.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
# 🚀 Patchcord
1+
# 🚀 Patchcord Desktop
22

33
> A modern, developer-focused IRC client built with Next.js and Tauri
44
55
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6-
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/patchcord/patchcord/releases)
7-
[![Build Status](https://img.shields.io/github/actions/workflow/status/patchcord/patchcord/release-prod.yml?branch=main)](https://github.com/patchcord/patchcord/actions)
8-
9-
![Patchcord Banner](docs/assets/banner.png)
6+
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/hijak/patchcord-desktop/releases)
7+
[![Build Status](https://img.shields.io/github/actions/workflow/status/hijak/patchcord-desktop/release-prod.yml?branch=main)](https://github.com/hijak/patchcord-desktop/actions)
108

119
## ✨ Features
1210

@@ -25,7 +23,7 @@
2523

2624
### Desktop Application
2725

28-
Download the latest release for your platform from the [Releases page](https://github.com/patchcord/patchcord/releases):
26+
Download the latest release for your platform from the [Releases page](https://github.com/hijak/patchcord-desktop/releases):
2927

3028
| Platform | Package |
3129
|----------|---------|
@@ -37,8 +35,8 @@ Download the latest release for your platform from the [Releases page](https://g
3735

3836
```bash
3937
# Clone the repository
40-
git clone https://github.com/patchcord/patchcord.git
41-
cd patchcord/frontend
38+
git clone https://github.com/hijak/patchcord-desktop.git
39+
cd patchcord-desktop
4240

4341
# Install dependencies
4442
pnpm install
@@ -165,7 +163,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
165163

166164
- 📧 Email: support@patchcord.dev
167165
- 💬 IRC: #patchcord on Libera.Chat
168-
- 🐛 Issues: [GitHub Issues](https://github.com/patchcord/patchcord/issues)
166+
- 🐛 Issues: [GitHub Issues](https://github.com/hijak/patchcord-desktop/issues)
169167

170168
---
171169

components/irc/code-block.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { useEffect, useState, useCallback } from "react"
44
import { Check, Copy } from "lucide-react"
55
import { useIRCStore } from "@/lib/store"
66
import { getThemeById } from "@/lib/themes"
7+
import { isLiveBuild } from "@/lib/build-mode"
8+
import { shellOpenExternal } from "@/lib/external-links"
79
import { hasIRCFormatting, parseIRCColors, getIRCColor, stripIRCFormatting } from "@/lib/irc-colors"
810

911
interface CodeBlockProps {
@@ -135,16 +137,25 @@ function parseInlineWithinSpan(text: string, startKey: number): React.ReactNode[
135137
</code>
136138
)
137139
} else if (m[3]) {
140+
const url = m[3]
138141
elements.push(
139142
<a
140143
key={`url-${key++}`}
141-
href={m[3]}
144+
href={url}
142145
target="_blank"
143146
rel="noopener noreferrer"
144147
className="text-blue-400 underline decoration-blue-400/30 underline-offset-2 transition-colors hover:text-blue-300 hover:decoration-blue-300/50"
145148
style={{ overflowWrap: "anywhere", wordBreak: "break-all" }}
149+
onClick={(e) => {
150+
if (isLiveBuild) {
151+
e.preventDefault()
152+
shellOpenExternal(url).catch(() => {
153+
window.open(url, "_blank", "noopener,noreferrer")
154+
})
155+
}
156+
}}
146157
>
147-
{m[3]}
158+
{url}
148159
</a>
149160
)
150161
}

components/irc/irc-client.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,12 @@ export function IRCClient() {
210210
return (
211211
<div className="flex h-screen w-screen items-center justify-center bg-background">
212212
<div className="flex flex-col items-center gap-3">
213-
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
214-
<span className="font-mono text-lg font-bold text-primary-foreground">P</span>
215-
</div>
213+
{/* eslint-disable-next-line @next/next/no-img-element */}
214+
<img
215+
src="/patchcord-logo.svg"
216+
alt="Patchcord"
217+
className="h-10 w-10 rounded"
218+
/>
216219
<p className="font-mono text-xs text-muted-foreground animate-pulse">Loading Patchcord...</p>
217220
</div>
218221
</div>

components/irc/message-area.tsx

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import type { IRCMessage, MessageDensity, TimestampFormat } from "@/lib/types"
1010
import { ScrollArea } from "@/components/ui/scroll-area"
1111
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip"
1212
import { ImagePreview } from "./image-preview"
13+
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "@/components/ui/context-menu"
14+
import { emitInputInsert } from "@/lib/input-bridge"
1315

1416
function formatTimestamp(date: Date, format: TimestampFormat): string {
1517
const h = date.getHours()
@@ -124,9 +126,107 @@ function MessageLine({
124126
// Image URLs for preview
125127
const imageUrls = inlineImagePreviews ? extractImageUrls(message.content) : []
126128

129+
let lastContextSelection = ""
130+
131+
const getSelectedText = () => {
132+
if (typeof window === "undefined") return ""
133+
const sel = window.getSelection()
134+
return sel ? sel.toString() : ""
135+
}
136+
137+
const copyToClipboard = async (text: string) => {
138+
if (!text) return
139+
try {
140+
if (navigator.clipboard && navigator.clipboard.writeText) {
141+
await navigator.clipboard.writeText(text)
142+
return
143+
}
144+
} catch {
145+
// fall through to fallback
146+
}
147+
try {
148+
const textarea = document.createElement("textarea")
149+
textarea.value = text
150+
textarea.style.position = "fixed"
151+
textarea.style.opacity = "0"
152+
document.body.appendChild(textarea)
153+
textarea.select()
154+
document.execCommand("copy")
155+
document.body.removeChild(textarea)
156+
} catch {
157+
// ignore
158+
}
159+
}
160+
161+
const wrapWithContextMenu = (node: React.ReactNode) => {
162+
const nick = message.nickname
163+
const baseText = message.content
164+
const serverId = message.serverId
165+
166+
const handleContextMenuCapture: React.MouseEventHandler<HTMLDivElement> = () => {
167+
const sel = getSelectedText().trim()
168+
if (sel) {
169+
lastContextSelection = sel
170+
}
171+
}
172+
173+
const handleCopySelection = async () => {
174+
const txt = (lastContextSelection || getSelectedText().trim())
175+
if (txt) await copyToClipboard(txt)
176+
}
177+
178+
const handleCopyMessage = async () => {
179+
if (baseText) await copyToClipboard(baseText)
180+
}
181+
182+
const handleQuoteUser = () => {
183+
if (!nick) return
184+
emitInputInsert({ text: `@${nick} `, mode: "append" })
185+
}
186+
187+
const handleQuoteText = () => {
188+
const sel = getSelectedText().trim()
189+
const text = sel || baseText
190+
if (!text) return
191+
const quoted = `> ${nick}: ${text}`
192+
emitInputInsert({ text: quoted, mode: "append" })
193+
}
194+
195+
const handleWhoisUser = () => {
196+
if (!nick) return
197+
emitInputInsert({ text: `/whois ${nick}`, mode: "replace" })
198+
}
199+
200+
const handleDmUser = () => {
201+
if (!nick || !serverId) return
202+
// Open or focus DM with this user
203+
useIRCStore.getState().openDM(serverId, nick)
204+
}
205+
206+
return (
207+
<ContextMenu>
208+
<ContextMenuTrigger asChild>
209+
<div onContextMenuCapture={handleContextMenuCapture}>
210+
{node}
211+
</div>
212+
</ContextMenuTrigger>
213+
<ContextMenuContent>
214+
<ContextMenuItem onSelect={handleCopySelection}>Copy selection</ContextMenuItem>
215+
<ContextMenuItem onSelect={handleCopyMessage}>Copy entire message</ContextMenuItem>
216+
<ContextMenuSeparator />
217+
<ContextMenuItem onSelect={handleQuoteUser}>Quote user</ContextMenuItem>
218+
<ContextMenuItem onSelect={handleQuoteText}>Quote text</ContextMenuItem>
219+
<ContextMenuSeparator />
220+
<ContextMenuItem onSelect={handleWhoisUser}>Whois user</ContextMenuItem>
221+
<ContextMenuItem onSelect={handleDmUser}>DM user</ContextMenuItem>
222+
</ContextMenuContent>
223+
</ContextMenu>
224+
)
225+
}
226+
127227
// Nick change
128228
if (message.type === "nick_change") {
129-
return (
229+
return wrapWithContextMenu(
130230
<div className={cn(rowBase, paddingClass)} style={{ fontSize }}>
131231
<TimestampBadge date={message.timestamp} format={timestampFormat} fontSize={fontSize} />
132232
<span className="min-w-0 text-muted-foreground/60 italic" style={{ overflowWrap: "anywhere", wordBreak: "break-word" }}>
@@ -138,7 +238,7 @@ function MessageLine({
138238

139239
// Kick
140240
if (message.type === "kick") {
141-
return (
241+
return wrapWithContextMenu(
142242
<div className={cn(rowBase, "bg-red-500/5 border-l-2 border-red-500/30", paddingClass)} style={{ fontSize }}>
143243
<TimestampBadge date={message.timestamp} format={timestampFormat} fontSize={fontSize} />
144244
<span className="min-w-0 text-red-400/80" style={{ overflowWrap: "anywhere", wordBreak: "break-word" }}>
@@ -150,7 +250,7 @@ function MessageLine({
150250

151251
// Mode change
152252
if (message.type === "mode") {
153-
return (
253+
return wrapWithContextMenu(
154254
<div className={cn(rowBase, paddingClass)} style={{ fontSize }}>
155255
<TimestampBadge date={message.timestamp} format={timestampFormat} fontSize={fontSize} />
156256
<span className="min-w-0 text-muted-foreground" style={{ overflowWrap: "anywhere", wordBreak: "break-word" }}>
@@ -162,7 +262,7 @@ function MessageLine({
162262

163263
// CTCP
164264
if (message.type === "ctcp") {
165-
return (
265+
return wrapWithContextMenu(
166266
<div className={cn(rowBase, paddingClass)} style={{ fontSize }}>
167267
<TimestampBadge date={message.timestamp} format={timestampFormat} fontSize={fontSize} />
168268
<span className="min-w-0 text-cyan-400/80" style={{ overflowWrap: "anywhere", wordBreak: "break-word" }}>
@@ -173,7 +273,7 @@ function MessageLine({
173273
}
174274

175275
if (message.type === "join" || message.type === "part" || message.type === "quit") {
176-
return (
276+
return wrapWithContextMenu(
177277
<div className={cn(rowBase, paddingClass)} style={{ fontSize }}>
178278
<TimestampBadge date={message.timestamp} format={timestampFormat} fontSize={fontSize} />
179279
<span className="min-w-0 text-muted-foreground/60 italic" style={{ overflowWrap: "anywhere", wordBreak: "break-word" }}>
@@ -184,7 +284,7 @@ function MessageLine({
184284
}
185285

186286
if (message.type === "system") {
187-
return (
287+
return wrapWithContextMenu(
188288
<div className={cn(rowBase, paddingClass)} style={{ fontSize }}>
189289
<TimestampBadge date={message.timestamp} format={timestampFormat} fontSize={fontSize} />
190290
<span className="min-w-0 text-muted-foreground" style={{ overflowWrap: "anywhere", wordBreak: "break-word" }}>*** {message.content}</span>
@@ -193,7 +293,7 @@ function MessageLine({
193293
}
194294

195295
if (message.type === "notice") {
196-
return (
296+
return wrapWithContextMenu(
197297
<div className={cn(rowBase, "bg-yellow-500/5 border-l-2 border-yellow-500/30", paddingClass)} style={{ fontSize }}>
198298
<TimestampBadge date={message.timestamp} format={timestampFormat} fontSize={fontSize} />
199299
<span className="shrink-0 font-bold text-yellow-500">-{message.nickname}-</span>
@@ -205,7 +305,7 @@ function MessageLine({
205305
}
206306

207307
if (message.type === "action") {
208-
return (
308+
return wrapWithContextMenu(
209309
<div className={cn(rowBase, paddingClass)} style={{ fontSize }}>
210310
<TimestampBadge date={message.timestamp} format={timestampFormat} fontSize={fontSize} />
211311
<span className="min-w-0 italic text-foreground/80" style={{ overflowWrap: "anywhere", wordBreak: "break-word" }}>
@@ -231,7 +331,7 @@ function MessageLine({
231331
const prefix = user.isOp ? "@" : user.isVoiced ? "+" : ""
232332
return prefix ? `${prefix}${message.nickname}` : message.nickname
233333
})()
234-
return (
334+
return wrapWithContextMenu(
235335
<div
236336
className={cn(
237337
rowBase,

components/irc/message-input.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils"
77
import { isLiveBuild } from "@/lib/build-mode"
88
import { sendNativeRaw } from "@/lib/irc-native"
99
import { ColorPicker } from "./color-picker"
10+
import { subscribeInputInsert } from "@/lib/input-bridge"
1011

1112
const IRC_COMMANDS = [
1213
{ cmd: "/join", desc: "Join a channel", usage: "/join #channel" },
@@ -416,6 +417,29 @@ export function MessageInput() {
416417
setValue(words.join(" "))
417418
}, [value, channel, tabCompleteIndex])
418419

420+
// Allow other components (e.g., message context menu) to insert or quote text into the input.
421+
useEffect(() => {
422+
const unsubscribe = subscribeInputInsert(({ text, mode = "append" }) => {
423+
setValue((prev) => {
424+
if (mode === "replace" || !prev) return text
425+
const needsSpace = !prev.endsWith(" ") && !text.startsWith(" ")
426+
return prev + (needsSpace ? " " : "") + text
427+
})
428+
429+
// Move cursor to end after React state updates.
430+
setTimeout(() => {
431+
const input = inputRef.current
432+
if (!input) return
433+
const len = input.value.length
434+
input.setSelectionRange(len, len)
435+
input.focus()
436+
setSelectionStart(len)
437+
setSelectionEnd(len)
438+
}, 0)
439+
})
440+
return unsubscribe
441+
}, [])
442+
419443
const handleKeyDown = (e: React.KeyboardEvent) => {
420444
if (e.key === "Tab") {
421445
e.preventDefault()

lib/external-links.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { isLiveBuild } from "./build-mode"
2+
3+
// Thin wrapper so we can centralize external link behavior.
4+
export async function shellOpenExternal(url: string): Promise<void> {
5+
if (!isLiveBuild) {
6+
window.open(url, "_blank", "noopener,noreferrer")
7+
return
8+
}
9+
10+
try {
11+
// Dynamically import Tauri shell only when available (desktop).
12+
const { open } = await import("@tauri-apps/api/shell")
13+
await open(url)
14+
} catch {
15+
// Fallback to normal browser open if shell is unavailable.
16+
window.open(url, "_blank", "noopener,noreferrer")
17+
}
18+
}
19+

lib/input-bridge.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
export type InputInsertMode = "replace" | "append"
2+
3+
export interface InputInsertPayload {
4+
text: string
5+
mode?: InputInsertMode
6+
}
7+
8+
const EVENT_NAME = "patchcord:input-insert"
9+
10+
export function emitInputInsert(payload: InputInsertPayload) {
11+
if (typeof window === "undefined") return
12+
const detail: InputInsertPayload = {
13+
mode: payload.mode ?? "append",
14+
text: payload.text,
15+
}
16+
window.dispatchEvent(new CustomEvent<InputInsertPayload>(EVENT_NAME, { detail }))
17+
}
18+
19+
export function subscribeInputInsert(
20+
handler: (payload: InputInsertPayload) => void
21+
): () => void {
22+
if (typeof window === "undefined") return () => {}
23+
24+
const listener = (event: Event) => {
25+
const custom = event as CustomEvent<InputInsertPayload>
26+
if (!custom.detail) return
27+
handler(custom.detail)
28+
}
29+
30+
window.addEventListener(EVENT_NAME, listener as EventListener)
31+
return () => window.removeEventListener(EVENT_NAME, listener as EventListener)
32+
}
33+

lib/store.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,6 +1206,17 @@ export const useIRCStore = create<IRCStore>((set, get) => ({
12061206
case 'status':
12071207
if (event.status) get().updateServerStatus(event.server_id, event.status)
12081208
break
1209+
case 'latency': {
1210+
const ms = event.content ? parseInt(event.content, 10) : NaN
1211+
if (!Number.isNaN(ms) && Number.isFinite(ms) && ms >= 0) {
1212+
set((state) => ({
1213+
servers: state.servers.map((s) =>
1214+
s.id === event.server_id ? { ...s, latency: ms } : s
1215+
),
1216+
}))
1217+
}
1218+
break
1219+
}
12091220
case 'raw_in':
12101221
case 'raw_out':
12111222
if (event.raw) {

0 commit comments

Comments
 (0)