From 1e197556481181dbf1f0239f4ec2740cfa5aa790 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Sat, 21 Jun 2025 11:40:02 -0700 Subject: initial commit --- src/main/index.ts | 132 ++++++++ src/preload/index.d.ts | 19 ++ src/preload/index.ts | 19 ++ src/renderer/index.html | 17 + src/renderer/src/App.tsx | 136 ++++++++ src/renderer/src/assets/base.css | 477 ++++++++++++++++++++++++++++ src/renderer/src/assets/main.css | 6 + src/renderer/src/components/ConfigModal.tsx | 148 +++++++++ src/renderer/src/components/Message.tsx | 260 +++++++++++++++ src/renderer/src/env.d.ts | 1 + src/renderer/src/main.tsx | 11 + src/renderer/src/types/discord.ts | 45 +++ 12 files changed, 1271 insertions(+) create mode 100644 src/main/index.ts create mode 100644 src/preload/index.d.ts create mode 100644 src/preload/index.ts create mode 100644 src/renderer/index.html create mode 100644 src/renderer/src/App.tsx create mode 100644 src/renderer/src/assets/base.css create mode 100644 src/renderer/src/assets/main.css create mode 100644 src/renderer/src/components/ConfigModal.tsx create mode 100644 src/renderer/src/components/Message.tsx create mode 100644 src/renderer/src/env.d.ts create mode 100644 src/renderer/src/main.tsx create mode 100644 src/renderer/src/types/discord.ts (limited to 'src') 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 }> + +async function initStore(): Promise { + 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> + setChannelNickname: (channelId: string, nickname: string) => Promise + removeChannelNickname: (channelId: string) => Promise + getChannelList: () => Promise> + } + } + } +} + +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 @@ + + + + + tiny-discord-feed + + + + + +
+ + + 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([]) + const [channelNicknames, setChannelNicknames] = useState>({}) + const [isConfigOpen, setIsConfigOpen] = useState(false) + const [isMouseInWindow, setIsMouseInWindow] = useState(true) + + // Load channel nicknames on mount + useEffect(() => { + const loadChannelNicknames = async (): Promise => { + 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 ( +
setIsMouseInWindow(true)} + onMouseLeave={() => setIsMouseInWindow(false)} + > + {/* Header */} +
+
tiny-discord-feed
+
+
+ {Object.keys(channelNicknames).length} channels configured +
+ +
+
+ + {/* Messages */} +
+ {(() => { + if (Object.keys(channelNicknames).length === 0) { + return ( +
+
⚙️
+
No channels configured
+
+ Click "Configure" to add channels to monitor +
+
+ ) + } + + // 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 ( +
+
💬
+
No messages from configured channels yet
+
+ Messages will appear here when received from your configured channels +
+
+ ) + } + + return filteredMessages.map((msg, i) => ( + + )) + })()} +
+ + { + setIsConfigOpen(false) + // Reload channel nicknames after config changes + window.electron.config.getChannelNicknames().then(setChannelNicknames) + }} + /> +
+ ) +} + +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([]) + const [newChannelId, setNewChannelId] = useState('') + const [newNickname, setNewNickname] = useState('') + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (isOpen) { + loadChannels() + } + }, [isOpen]) + + const loadChannels = async (): Promise => { + try { + const channelList = await window.electron.config.getChannelList() + setChannels(channelList) + } catch (error) { + console.error('Failed to load channels:', error) + } + } + + const handleAddChannel = async (): Promise => { + 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 => { + 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 ( +
+
e.stopPropagation()}> +
+

Channel Configuration

+ +
+ +
+
+

Add Channel

+
+ setNewChannelId(e.target.value)} + onKeyPress={handleKeyPress} + /> + setNewNickname(e.target.value)} + onKeyPress={handleKeyPress} + /> + +
+
+ +
+

Configured Channels ({channels.length})

+ {channels.length === 0 ? ( +
+

No channels configured yet.

+

Add channel IDs above to filter messages.

+
+ ) : ( +
+ {channels.map((channel) => ( +
+
+
{channel.nickname}
+
{channel.id}
+
+ +
+ ))} +
+ )} +
+
+ +
+ +
+
+
+ ) +} \ 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, + `@${mention.username}` + ) + }) + } + + parsedContent = parsedContent.replace(/<@!?(\d+)>/g, '@unknown') + + // Replace Discord emote syntax <:name:id> and 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 = { + "'": ''', + '"': '"', + '<': '<', + '>': '>', + '&': '&' + } + return entities[char] || char + }) + const safeUrl = `https://cdn.discordapp.com/emojis/${id}.${extension}?size=64` + return `${safeName}` + }) + + // Also handle any remaining malformed emote patterns + parsedContent = parsedContent.replace(/]*>/g, (match) => { + // Extract name if possible for fallback + const nameMatch = match.match(/]+)/) + const fallbackName = nameMatch ? nameMatch[1] : 'emote' + return `[${fallbackName}]` + }) + + // First, protect existing img tags by replacing them with placeholders + const imgPlaceholders: string[] = [] + parsedContent = parsedContent.replace(/]*>/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 `${url}` + }) + + // 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, '
') + + return { html: parsedContent, imageUrls } +} + +const Message: React.FC = ({ 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 ( +
+ +
+ ) + } + + const stickerImageUrl = `https://media.discordapp.net/stickers/${sticker_id}.${STICKER_TYPE_TO_EXTENSION[sticker_type]}?size=128` + return ( +
+ {sticker_name} { + console.error('Static sticker failed to load:', stickerImageUrl) + e.currentTarget.style.display = 'none' + }} + /> +
+ ) + } + + return ( +
+ {author_name} + +
+
+ {displayName} + {channelNickname && #{channelNickname}} + {displayTime} +
+ +
+ {(() => { + if (!content) return null + + const parsed = parseMentionsAndEmotes(content, mentions) + + return ( + <> +
+ + {/* Render extracted image URLs as React components */} + {parsed.imageUrls.map((imageUrl, index) => ( +
+ Content image { + e.currentTarget.style.display = 'none' + }} + /> +
+ ))} + + ) + })()} + + {renderSticker()} + + {/* Render all image attachments */} + {imageAttachments.map((attachment, index) => ( +
+ {attachment.filename} { + console.error('Attachment image failed to load:', attachment.filename) + e.currentTarget.style.display = 'none' + }} + /> +
+ ))} + + {/* Render non-image attachments as clickable links */} + {nonImageAttachments.map((attachment, index) => ( + + ))} +
+
+
+ ) +} + +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 @@ +/// 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( + + + +) 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 = { + 1: 'webp', + 2: 'png', + 3: 'json', + 4: 'gif' +} -- cgit v1.2.3