aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2025-06-21 11:40:02 -0700
committerPinapelz <yukais@pinapelz.com>2025-06-21 11:40:02 -0700
commit1e197556481181dbf1f0239f4ec2740cfa5aa790 (patch)
tree2766bf9f085fac6bfaa8c8c71d4403f253c75d6d /src
initial commit
Diffstat (limited to 'src')
-rw-r--r--src/main/index.ts132
-rw-r--r--src/preload/index.d.ts19
-rw-r--r--src/preload/index.ts19
-rw-r--r--src/renderer/index.html17
-rw-r--r--src/renderer/src/App.tsx136
-rw-r--r--src/renderer/src/assets/base.css477
-rw-r--r--src/renderer/src/assets/main.css6
-rw-r--r--src/renderer/src/components/ConfigModal.tsx148
-rw-r--r--src/renderer/src/components/Message.tsx260
-rw-r--r--src/renderer/src/env.d.ts1
-rw-r--r--src/renderer/src/main.tsx11
-rw-r--r--src/renderer/src/types/discord.ts45
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 &quot;Configure&quot; 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> = {
+ "'": '&#39;',
+ '"': '&quot;',
+ '<': '&lt;',
+ '>': '&gt;',
+ '&': '&amp;'
+ }
+ 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'
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage