diff options
| author | Pinapelz <yukais@pinapelz.com> | 2025-06-21 11:40:02 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2025-06-21 11:40:02 -0700 |
| commit | 1e197556481181dbf1f0239f4ec2740cfa5aa790 (patch) | |
| tree | 2766bf9f085fac6bfaa8c8c71d4403f253c75d6d /src | |
initial commit
Diffstat (limited to 'src')
| -rw-r--r-- | src/main/index.ts | 132 | ||||
| -rw-r--r-- | src/preload/index.d.ts | 19 | ||||
| -rw-r--r-- | src/preload/index.ts | 19 | ||||
| -rw-r--r-- | src/renderer/index.html | 17 | ||||
| -rw-r--r-- | src/renderer/src/App.tsx | 136 | ||||
| -rw-r--r-- | src/renderer/src/assets/base.css | 477 | ||||
| -rw-r--r-- | src/renderer/src/assets/main.css | 6 | ||||
| -rw-r--r-- | src/renderer/src/components/ConfigModal.tsx | 148 | ||||
| -rw-r--r-- | src/renderer/src/components/Message.tsx | 260 | ||||
| -rw-r--r-- | src/renderer/src/env.d.ts | 1 | ||||
| -rw-r--r-- | src/renderer/src/main.tsx | 11 | ||||
| -rw-r--r-- | src/renderer/src/types/discord.ts | 45 |
12 files changed, 1271 insertions, 0 deletions
diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..76c508a --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,132 @@ +import { app, BrowserWindow, shell, ipcMain } from 'electron' +import { join } from 'path' +import { electronApp, optimizer, is, platform } from '@electron-toolkit/utils' +import { WebSocketServer, WebSocket } from 'ws' +import icon from '../../resources/icon.png?asset' + +let mainWindow: BrowserWindow +let store: import('electron-store').default<{ channelNicknames: Record<string, string> }> + +async function initStore(): Promise<void> { + const Store = (await import('electron-store')).default + store = new Store({ + defaults: { + channelNicknames: {} + } + }) +} + +function createWindow(): void { + mainWindow = new BrowserWindow({ + width: 900, + height: 670, + show: false, + title: 'tiny-discord-feed', + autoHideMenuBar: true, + ...(platform.isLinux ? { icon } : {}), + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + contextIsolation: true + } + }) + + mainWindow.on('ready-to-show', () => { + mainWindow.show() + }) + + mainWindow.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + return { action: 'deny' } + }) + + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + } +} + +function setupWebSocket(): void { + if (!mainWindow) { + console.error('Cannot setup WebSocket: mainWindow not available') + return + } + + const wss = new WebSocketServer({ port: 8765, host: '0.0.0.0' }) + + wss.on('connection', (ws: WebSocket) => { + console.log('New WebSocket client connected') + ws.on('message', (msg: Buffer) => { + try { + const parsed = JSON.parse(msg.toString()) + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('new-discord-message', parsed) + } + } catch (e) { + console.error('Invalid JSON received:', e) + } + }) + + ws.on('error', (error) => { + console.error('WebSocket client error:', error) + }) + }) + + wss.on('error', (error) => { + console.error('WebSocket server error:', error) + }) + + console.log('WebSocket server listening on 0.0.0.0:8765') +} + +function setupIpcHandlers(): void { + // Get channel nicknames + ipcMain.handle('config:get-channel-nicknames', () => { + return store.get('channelNicknames') + }) + + // Set channel nickname + ipcMain.handle('config:set-channel-nickname', (_, channelId: string, nickname: string) => { + const nicknames = { ...store.get('channelNicknames') } + nicknames[channelId] = nickname + store.set('channelNicknames', nicknames) + return true + }) + + // Remove channel nickname + ipcMain.handle('config:remove-channel-nickname', (_, channelId: string) => { + const nicknames = { ...store.get('channelNicknames') } + delete nicknames[channelId] + store.set('channelNicknames', nicknames) + return true + }) + + // Get all channel nicknames as array + ipcMain.handle('config:get-channel-list', () => { + const nicknames = store.get('channelNicknames') + return Object.entries(nicknames).map(([id, nickname]) => ({ id, nickname })) + }) +} + +app.whenReady().then(async () => { + electronApp.setAppUserModelId('com.electron') + app.on('browser-window-created', (_, window) => { + optimizer.watchWindowShortcuts(window) + }) + + await initStore() + createWindow() + setupWebSocket() + setupIpcHandlers() + + app.on('activate', function () { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) +}) + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } +}) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts new file mode 100644 index 0000000..dd64a3f --- /dev/null +++ b/src/preload/index.d.ts @@ -0,0 +1,19 @@ +declare global { + interface Window { + electron: { + ipcRenderer: { + send: (channel: string, data?: unknown) => void + on: (channel: string, func: (...args: unknown[]) => void) => void + removeListener: (channel: string, func: (...args: unknown[]) => void) => void + } + config: { + getChannelNicknames: () => Promise<Record<string, string>> + setChannelNickname: (channelId: string, nickname: string) => Promise<boolean> + removeChannelNickname: (channelId: string) => Promise<boolean> + getChannelList: () => Promise<Array<{ id: string; nickname: string }>> + } + } + } +} + +export {} diff --git a/src/preload/index.ts b/src/preload/index.ts new file mode 100644 index 0000000..84ff03c --- /dev/null +++ b/src/preload/index.ts @@ -0,0 +1,19 @@ +import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron' + +contextBridge.exposeInMainWorld('electron', { + ipcRenderer: { + send: (channel: string, data?: unknown) => ipcRenderer.send(channel, data), + on: (channel: string, func: (...args: unknown[]) => void) => + ipcRenderer.on(channel, (_: IpcRendererEvent, ...args) => func(...args)), + removeListener: (channel: string, func: (...args: unknown[]) => void) => + ipcRenderer.removeListener(channel, func) + }, + config: { + getChannelNicknames: () => ipcRenderer.invoke('config:get-channel-nicknames'), + setChannelNickname: (channelId: string, nickname: string) => + ipcRenderer.invoke('config:set-channel-nickname', channelId, nickname), + removeChannelNickname: (channelId: string) => + ipcRenderer.invoke('config:remove-channel-nickname', channelId), + getChannelList: () => ipcRenderer.invoke('config:get-channel-list') + } +}) diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..edffc05 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,17 @@ +<!doctype html> +<html> + <head> + <meta charset="UTF-8" /> + <title>tiny-discord-feed</title> + <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> + <meta + http-equiv="Content-Security-Policy" + content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://discord.com https://cdn.discordapp.com https://media.discordapp.net" + /> + </head> + + <body> + <div id="root"></div> + <script type="module" src="/src/main.tsx"></script> + </body> +</html> diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx new file mode 100644 index 0000000..bbd949d --- /dev/null +++ b/src/renderer/src/App.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from 'react' +import Message from './components/Message' +import ConfigModal from './components/ConfigModal' +import { DiscordMessage } from './types/discord' + +function App(): React.JSX.Element { + const [messages, setMessages] = useState<DiscordMessage[]>([]) + const [channelNicknames, setChannelNicknames] = useState<Record<string, string>>({}) + const [isConfigOpen, setIsConfigOpen] = useState(false) + const [isMouseInWindow, setIsMouseInWindow] = useState(true) + + // Load channel nicknames on mount + useEffect(() => { + const loadChannelNicknames = async (): Promise<void> => { + try { + const nicknames = await window.electron.config.getChannelNicknames() + setChannelNicknames(nicknames) + } catch (error) { + console.error('Failed to load channel nicknames:', error) + } + } + + loadChannelNicknames() + }, []) + + useEffect(() => { + const listener = (...args: unknown[]): void => { + const data = args[0] as DiscordMessage + + if (data) { + setMessages((prev) => { + const existingMessage = prev.find((msg) => msg.id === data.id) + if (existingMessage) { + return prev + } + return [data, ...prev.slice(0, 300)] + }) + } + } + + window.electron.ipcRenderer.on('new-discord-message', listener) + return () => { + window.electron.ipcRenderer.removeListener('new-discord-message', listener) + } + }, []) + + return ( + <div + id="app-container" + onMouseEnter={() => setIsMouseInWindow(true)} + onMouseLeave={() => setIsMouseInWindow(false)} + > + {/* Header */} + <div + className="app-header" + style={{ + opacity: isMouseInWindow ? 1 : 0, + transform: isMouseInWindow ? 'translateY(0)' : 'translateY(-100%)', + transition: 'opacity 0.3s ease, transform 0.3s ease', + pointerEvents: isMouseInWindow ? 'auto' : 'none' + }} + > + <div className="app-title">tiny-discord-feed</div> + <div className="header-controls"> + <div className="channel-count"> + {Object.keys(channelNicknames).length} channels configured + </div> + <button className="config-button" onClick={() => setIsConfigOpen(true)}> + ⚙️ Configure + </button> + </div> + </div> + + {/* Messages */} + <div id="messages"> + {(() => { + if (Object.keys(channelNicknames).length === 0) { + return ( + <div className="empty-state"> + <div className="icon">⚙️</div> + <div>No channels configured</div> + <div style={{ fontSize: '14px', marginTop: '8px', opacity: 0.7 }}> + Click "Configure" to add channels to monitor + </div> + </div> + ) + } + + // Filter messages from configured channels and limit to 30 displayed + const filteredMessages = messages + .filter((msg) => channelNicknames[msg.channel]) + .filter( + (msg) => + !( + (msg.content === '' || msg.content === undefined) && + !msg.sticker_id && + (!msg.attachments || msg.attachments.length === 0) + ) + ) + .slice(0, 30) + + if (filteredMessages.length === 0) { + return ( + <div className="empty-state"> + <div className="icon">💬</div> + <div>No messages from configured channels yet</div> + <div style={{ fontSize: '14px', marginTop: '8px', opacity: 0.7 }}> + Messages will appear here when received from your configured channels + </div> + </div> + ) + } + + return filteredMessages.map((msg, i) => ( + <Message + key={msg.id || i} + message={msg} + channelNickname={channelNicknames[msg.channel] || msg.channel} + /> + )) + })()} + </div> + + <ConfigModal + isOpen={isConfigOpen} + onClose={() => { + setIsConfigOpen(false) + // Reload channel nicknames after config changes + window.electron.config.getChannelNicknames().then(setChannelNicknames) + }} + /> + </div> + ) +} + +export default App diff --git a/src/renderer/src/assets/base.css b/src/renderer/src/assets/base.css new file mode 100644 index 0000000..636c03c --- /dev/null +++ b/src/renderer/src/assets/base.css @@ -0,0 +1,477 @@ +:root { + --background: #313338; + --sidebar: #2b2d31; + --channel: #232428; + --text-normal: #dbdee1; + --text-muted: #a6a7ab; + --accent-blue: #00a8fc; + --message-bg: transparent; + --radius: 6px; + font-size: 15px; + font-family: "Segoe UI", system-ui, sans-serif; +} + +html,body{ + margin:0; + padding:0; + height:100%; + background:var(--background); + color:var(--text-normal); + overflow:hidden; +} + +/* Header (optional) */ +#header{ + background:var(--sidebar); + padding:4px 8px; + font-weight:600; + font-size:1rem; + display:flex; + align-items:center; +} + +/* ================= App Container ================= */ +#app-container { + height: 100vh; + display: flex; + flex-direction: column; +} + +/* ================= App Header ================= */ +.app-header { + background: var(--sidebar); + border-bottom: 1px solid var(--channel); + padding: 12px 16px; + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; +} + +.app-title { + font-weight: 600; + font-size: 16px; + color: var(--text-normal); +} + +.header-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.channel-count { + font-size: 12px; + color: var(--text-muted); +} + +.config-button { + background: var(--accent-blue); + border: none; + border-radius: var(--radius); + padding: 8px 12px; + color: white; + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; +} + +.config-button:hover { + opacity: 0.8; +} + +/* ================= Modal Styles ================= */ +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: var(--background); + border: 1px solid var(--channel); + border-radius: 8px; + width: 90%; + max-width: 600px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.modal-header { + padding: 16px 20px; + border-bottom: 1px solid var(--channel); + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; +} + +.modal-header h2 { + margin: 0; + font-size: 18px; + color: var(--text-normal); +} + +.modal-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); +} + +.modal-close:hover { + background: var(--channel); + color: var(--text-normal); +} + +.modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +.modal-footer { + padding: 16px 20px; + border-top: 1px solid var(--channel); + display: flex; + justify-content: flex-end; + flex-shrink: 0; +} + +.add-channel-section { + margin-bottom: 24px; +} + +.add-channel-section h3 { + margin: 0 0 12px 0; + font-size: 14px; + color: var(--text-normal); + font-weight: 600; +} + +.input-group { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.input-group input { + background: var(--channel); + border: 1px solid var(--text-muted); + border-radius: var(--radius); + padding: 8px 12px; + color: var(--text-normal); + font-size: 13px; + min-width: 200px; + flex: 1; +} + +.input-group input:focus { + outline: none; + border-color: var(--accent-blue); +} + +.input-group button { + background: var(--accent-blue); + border: none; + border-radius: var(--radius); + padding: 8px 16px; + color: white; + font-size: 13px; + cursor: pointer; + white-space: nowrap; +} + +.input-group button:hover:not(:disabled) { + opacity: 0.8; +} + +.input-group button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.channel-list-section h3 { + margin: 0 0 12px 0; + font-size: 14px; + color: var(--text-normal); + font-weight: 600; +} + +.empty-channels { + text-align: center; + padding: 20px; + color: var(--text-muted); +} + +.empty-channels p { + margin: 4px 0; +} + +.channel-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.channel-item { + background: var(--channel); + border-radius: var(--radius); + padding: 12px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.channel-info { + flex: 1; +} + +.channel-nickname { + font-weight: 600; + color: var(--text-normal); + margin-bottom: 2px; +} + +.channel-id { + font-size: 11px; + color: var(--text-muted); + font-family: monospace; +} + +.remove-button { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: var(--radius); + font-size: 14px; +} + +.remove-button:hover { + background: #ff6b6b; + color: white; +} + +.close-button { + background: var(--channel); + border: 1px solid var(--text-muted); + border-radius: var(--radius); + padding: 8px 16px; + color: var(--text-normal); + font-size: 13px; + cursor: pointer; +} + +.close-button:hover { + background: var(--text-muted); + color: var(--background); +} + +/* ================= Message list ================= */ +#messages{ + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + padding: 8px; + scrollbar-width: thin; + scrollbar-color: var(--channel) transparent; + min-height: 0; +} + +#messages::-webkit-scrollbar{ + width: 5px; +} +#messages::-webkit-scrollbar-thumb{ + background: var(--channel); + border-radius: 3px; +} + +/* Empty state */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + font-size: 16px; + text-align: center; +} + +.empty-state .icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.msg{ + display: grid; + grid-template-columns: 40px 1fr; + gap: 12px; + padding: 8px 12px; + border-radius: var(--radius); + background: var(--message-bg); + margin-bottom: 4px; + align-items: start; +} + +.msg:hover{ + background: var(--channel); +} + +.avatar{ + width: 40px; + height: 40px; + border-radius: 50%; + background: #5865f2; + object-fit: cover; + flex-shrink: 0; +} + +.body{ + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +.meta{ + display: flex; + gap: 8px; + align-items: baseline; + margin-bottom: 2px; +} + +.username{ + font-weight: 600; + color: var(--text-normal); +} + +.channel-name { + font-size: 12px; + color: var(--text-muted); + background: var(--channel); + padding: 2px 6px; + border-radius: 10px; +} + +.timestamp{ + font-size: 12px; + color: var(--text-muted); + margin-left: auto; +} + +.content{ + word-wrap: break-word; + line-height: 1.6; + overflow-wrap: break-word; +} + +/* ================= Media Content ================= */ +.content img { + max-width: 400px; + max-height: 300px; + width: auto; + height: auto; + margin-top: 8px; + border-radius: var(--radius); + object-fit: contain; + display: block; +} + +.content .sticker-img { + max-width: 128px; + max-height: 128px; + margin-top: 6px; +} + +.attachment-container { + margin-top: 8px; +} + +code{ + background:#1e1f22; + padding:2px 4px; + border-radius:4px; + font-family:monospace; +} +pre{ + background:#1e1f22; + padding:6px; + border-radius:4px; + overflow-x:auto; + font-size:0.8rem; +} + +a{ + color:var(--accent-blue); + text-decoration:none; +} +a:hover{ text-decoration:underline; } + +/* ================= Mentions ================= */ +.mention { + background: #5865f2; + color: white; + padding: 2px 4px; + border-radius: 3px; + font-weight: 500; + font-size: 0.9em; +} + +/* ================= Inline Emotes ================= */ +.inline-emote { + display: inline !important; + vertical-align: middle !important; + height: 22px !important; + width: 22px !important; + max-height: 22px !important; + max-width: 22px !important; + min-height: 22px !important; + min-width: 22px !important; + object-fit: contain !important; + margin: 0 2px 0 2px !important; + border: none !important; + background: transparent !important; + position: relative !important; + top: 1px !important; +} + +/* ================= Lottie Stickers ================= */ +.lottie-sticker { + display: inline-block; + border-radius: var(--radius); + overflow: hidden; +} + +.lottie-sticker canvas { + display: block; + border-radius: var(--radius); +} diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css new file mode 100644 index 0000000..4c2db9e --- /dev/null +++ b/src/renderer/src/assets/main.css @@ -0,0 +1,6 @@ +@import './base.css'; + +#root { + height: 100vh; + width: 100vw; +} diff --git a/src/renderer/src/components/ConfigModal.tsx b/src/renderer/src/components/ConfigModal.tsx new file mode 100644 index 0000000..f927045 --- /dev/null +++ b/src/renderer/src/components/ConfigModal.tsx @@ -0,0 +1,148 @@ +import { useState, useEffect } from 'react' + +interface ConfigModalProps { + isOpen: boolean + onClose: () => void +} + +interface ChannelConfig { + id: string + nickname: string +} + +export default function ConfigModal({ + isOpen, + onClose +}: ConfigModalProps): React.JSX.Element | null { + const [channels, setChannels] = useState<ChannelConfig[]>([]) + const [newChannelId, setNewChannelId] = useState('') + const [newNickname, setNewNickname] = useState('') + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (isOpen) { + loadChannels() + } + }, [isOpen]) + + const loadChannels = async (): Promise<void> => { + try { + const channelList = await window.electron.config.getChannelList() + setChannels(channelList) + } catch (error) { + console.error('Failed to load channels:', error) + } + } + + const handleAddChannel = async (): Promise<void> => { + if (!newChannelId.trim() || !newNickname.trim()) { + return + } + + setIsLoading(true) + try { + await window.electron.config.setChannelNickname(newChannelId.trim(), newNickname.trim()) + setNewChannelId('') + setNewNickname('') + await loadChannels() + } catch (error) { + console.error('Failed to add channel:', error) + } finally { + setIsLoading(false) + } + } + + const handleRemoveChannel = async (channelId: string): Promise<void> => { + try { + await window.electron.config.removeChannelNickname(channelId) + await loadChannels() + } catch (error) { + console.error('Failed to remove channel:', error) + } + } + + const handleKeyPress = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter') { + handleAddChannel() + } + } + + if (!isOpen) { + return null + } + + return ( + <div className="modal-backdrop" onClick={onClose}> + <div className="modal-content" onClick={(e) => e.stopPropagation()}> + <div className="modal-header"> + <h2>Channel Configuration</h2> + <button className="modal-close" onClick={onClose}> + × + </button> + </div> + + <div className="modal-body"> + <div className="add-channel-section"> + <h3>Add Channel</h3> + <div className="input-group"> + <input + type="text" + placeholder="Channel ID" + value={newChannelId} + onChange={(e) => setNewChannelId(e.target.value)} + onKeyPress={handleKeyPress} + /> + <input + type="text" + placeholder="Nickname" + value={newNickname} + onChange={(e) => setNewNickname(e.target.value)} + onKeyPress={handleKeyPress} + /> + <button + onClick={handleAddChannel} + disabled={isLoading || !newChannelId.trim() || !newNickname.trim()} + > + Add + </button> + </div> + </div> + + <div className="channel-list-section"> + <h3>Configured Channels ({channels.length})</h3> + {channels.length === 0 ? ( + <div className="empty-channels"> + <p>No channels configured yet.</p> + <p>Add channel IDs above to filter messages.</p> + </div> + ) : ( + <div className="channel-list"> + {channels.map((channel) => ( + <div key={channel.id} className="channel-item"> + <div className="channel-info"> + <div className="channel-nickname">{channel.nickname}</div> + <div className="channel-id">{channel.id}</div> + </div> + <button + className="remove-button" + onClick={() => handleRemoveChannel(channel.id)} + title="Remove channel" + > + 🗑️ + </button> + </div> + ))} + </div> + )} + </div> + </div> + + <div className="modal-footer"> + <button className="close-button" onClick={onClose}> + Close + </button> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/src/renderer/src/components/Message.tsx b/src/renderer/src/components/Message.tsx new file mode 100644 index 0000000..c0c0ae7 --- /dev/null +++ b/src/renderer/src/components/Message.tsx @@ -0,0 +1,260 @@ +import React from 'react' +import Lottie from 'react-lottie-player' +import { DiscordMessage, DiscordUserMention, STICKER_TYPE_TO_EXTENSION } from '../types/discord' + +interface MessageProps { + message: DiscordMessage + channelNickname?: string +} + +const fallbackAvatar = 'https://cdn.discordapp.com/embed/avatars/0.png' +const avatarBaseUrl = 'https://cdn.discordapp.com/avatars/' + +interface ParsedContent { + html: string + imageUrls: string[] +} + +const parseMentionsAndEmotes = ( + content: string, + mentions?: DiscordUserMention[] +): ParsedContent => { + let parsedContent = content + const imageUrls: string[] = [] + + // Replace user mentions <@user_id> with usernames + if (mentions && mentions.length > 0) { + mentions.forEach((mention) => { + const mentionRegex = new RegExp(`<@!?${mention.id}>`, 'g') + parsedContent = parsedContent.replace( + mentionRegex, + `<span class="mention">@${mention.username}</span>` + ) + }) + } + + parsedContent = parsedContent.replace(/<@!?(\d+)>/g, '<span class="mention">@unknown</span>') + + // Replace Discord emote syntax <:name:id> and <a:name:id> with img tags FIRST + parsedContent = parsedContent.replace(/<(a?):([^:]+):(\d+)>/g, (_, animated, name, id) => { + // Validate that we have a proper ID + if (!id || id === '0' || !/^\d+$/.test(id) || !name.trim()) { + return `[${name || 'emote'}]` // Fallback for invalid emotes + } + + const extension = animated === 'a' ? 'gif' : 'webp' + const safeName = name.replace(/['"<>&]/g, (char) => { + const entities: Record<string, string> = { + "'": ''', + '"': '"', + '<': '<', + '>': '>', + '&': '&' + } + return entities[char] || char + }) + const safeUrl = `https://cdn.discordapp.com/emojis/${id}.${extension}?size=64` + return `<img src="${safeUrl}" alt="${safeName}" class="inline-emote" onerror="this.outerHTML='[${safeName}]';" />` + }) + + // Also handle any remaining malformed emote patterns + parsedContent = parsedContent.replace(/<a?:[^>]*>/g, (match) => { + // Extract name if possible for fallback + const nameMatch = match.match(/<a?:([^:>]+)/) + const fallbackName = nameMatch ? nameMatch[1] : 'emote' + return `[${fallbackName}]` + }) + + // First, protect existing img tags by replacing them with placeholders + const imgPlaceholders: string[] = [] + parsedContent = parsedContent.replace(/<img[^>]*>/g, (imgTag) => { + const placeholder = `__IMG_PLACEHOLDER_${imgPlaceholders.length}__` + imgPlaceholders.push(imgTag) + return placeholder + }) + + // Now handle URLs safely + const urlRegex = /(https?:\/\/[^\s]+)/g + parsedContent = parsedContent.replace(urlRegex, (url) => { + // Check if URL ends with image extension + const imageExtensions = /\.(png|jpe?g|gif|webp)(\?[^\s]*)?$/i + if (imageExtensions.test(url)) { + imageUrls.push(url) + return '' // Remove image URL from content + } + return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>` + }) + + // Restore img tags from placeholders + imgPlaceholders.forEach((imgTag, index) => { + const placeholder = `__IMG_PLACEHOLDER_${index}__` + parsedContent = parsedContent.replace(placeholder, imgTag) + }) + + // Convert newlines to HTML line breaks + parsedContent = parsedContent.replace(/\n/g, '<br>') + + return { html: parsedContent, imageUrls } +} + +const Message: React.FC<MessageProps> = ({ message, channelNickname }) => { + const { + author, + author_name, + avatar_id, + nickname, + content, + time, + attachments, + sticker_id, + sticker_name, + sticker_type, + mentions + } = message + let avatarUrl = avatarBaseUrl + author + '/' + avatar_id + '.webp?size=80' + if (author === null || avatar_id === null) { + avatarUrl = fallbackAvatar + } + + const displayName = nickname || author_name + const displayTime = time + ? new Date(time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : '' + + // Separate image and non-image attachments + const imageAttachments = + attachments?.filter((att) => att.content_type?.startsWith('image/')) || [] + const nonImageAttachments = + attachments?.filter((att) => !att.content_type?.startsWith('image/')) || [] + + const renderSticker = (): React.JSX.Element | null => { + if (!sticker_id || sticker_type === undefined) return null + + // Type 3 is Lottie animation + if (sticker_type === 3) { + const lottieUrl = `https://discord.com/stickers/${sticker_id}.json` + return ( + <div className="attachment-container"> + <Lottie + loop + path={lottieUrl} + play + style={{ width: 128, height: 128 }} + className="sticker-img lottie-sticker" + /> + </div> + ) + } + + const stickerImageUrl = `https://media.discordapp.net/stickers/${sticker_id}.${STICKER_TYPE_TO_EXTENSION[sticker_type]}?size=128` + return ( + <div className="attachment-container"> + <img + src={stickerImageUrl} + alt={sticker_name} + className="sticker-img" + onError={(e) => { + console.error('Static sticker failed to load:', stickerImageUrl) + e.currentTarget.style.display = 'none' + }} + /> + </div> + ) + } + + return ( + <div className="msg"> + <img className="avatar" src={avatarUrl || fallbackAvatar} alt={author_name} /> + + <div className="body"> + <div className="meta"> + <span className="username">{displayName}</span> + {channelNickname && <span className="channel-name">#{channelNickname}</span>} + <span className="timestamp">{displayTime}</span> + </div> + + <div className="content"> + {(() => { + if (!content) return null + + const parsed = parseMentionsAndEmotes(content, mentions) + + return ( + <> + <div dangerouslySetInnerHTML={{ __html: parsed.html }} /> + + {/* Render extracted image URLs as React components */} + {parsed.imageUrls.map((imageUrl, index) => ( + <div key={index} className="attachment-container"> + <img + src={imageUrl} + alt="Content image" + style={{ + maxWidth: '400px', + maxHeight: '300px', + borderRadius: '6px', + marginTop: '8px', + display: 'block' + }} + onError={(e) => { + e.currentTarget.style.display = 'none' + }} + /> + </div> + ))} + </> + ) + })()} + + {renderSticker()} + + {/* Render all image attachments */} + {imageAttachments.map((attachment, index) => ( + <div key={`img-${attachment.id || index}`} className="attachment-container"> + <img + src={attachment.proxy_url || attachment.url} + alt={attachment.filename} + style={{ + maxWidth: '400px', + maxHeight: '300px', + borderRadius: '6px', + marginTop: '8px', + display: 'block' + }} + onError={(e) => { + console.error('Attachment image failed to load:', attachment.filename) + e.currentTarget.style.display = 'none' + }} + /> + </div> + ))} + + {/* Render non-image attachments as clickable links */} + {nonImageAttachments.map((attachment, index) => ( + <div key={`file-${attachment.id || index}`} className="attachment-container"> + <a + href={attachment.url} + target="_blank" + rel="noopener noreferrer" + className="file-attachment" + style={{ + display: 'inline-block', + padding: '8px 12px', + background: 'var(--channel)', + borderRadius: '6px', + textDecoration: 'none', + color: 'var(--accent-blue)', + marginTop: '8px' + }} + > + 📎 {attachment.filename} + </a> + </div> + ))} + </div> + </div> + </div> + ) +} + +export default Message diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/renderer/src/env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx new file mode 100644 index 0000000..5905ed1 --- /dev/null +++ b/src/renderer/src/main.tsx @@ -0,0 +1,11 @@ +import './assets/main.css' + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' + +createRoot(document.getElementById('root')!).render( + <StrictMode> + <App /> + </StrictMode> +) diff --git a/src/renderer/src/types/discord.ts b/src/renderer/src/types/discord.ts new file mode 100644 index 0000000..003da4d --- /dev/null +++ b/src/renderer/src/types/discord.ts @@ -0,0 +1,45 @@ +export interface DiscordMessage { + id: string + author: string + author_name: string + global_author_name: string + avatar_id: string + channel: string + + nickname?: string + content?: string + time?: string + + sticker_id?: string + sticker_name?: string + sticker_type?: number + + mentions?: DiscordUserMention[] + attachments?: DiscordAttachment[] +} + +export interface DiscordUserMention { + id: string + username: string + discriminator: string + avatar?: string +} + +export interface DiscordAttachment { + id: string + filename: string + size: number + url: string + proxy_url: string + width?: number + height?: number + content_type?: string + description?: string +} + +export const STICKER_TYPE_TO_EXTENSION: Record<number, string> = { + 1: 'webp', + 2: 'png', + 3: 'json', + 4: 'gif' +} |
