repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
Unified chat aggregator — Twitch + X + Kick in one…
stars
latest
clone command
git clone gitlawb://did:key:z6MkfpiH...NPsu/unified-chat-ag...git clone gitlawb://did:key:z6MkfpiH.../unified-chat-ag...5a763564sync from playground1d ago| #1 | import { useState, useEffect, useRef, useCallback, lazy, Suspense } from "react"; |
| #2 | |
| #3 | const TipPanel = lazy(() => import("./TipPanel.tsx")); |
| #4 | |
| #5 | // --------------------------------------------------------------------------- |
| #6 | // Types |
| #7 | // --------------------------------------------------------------------------- |
| #8 | |
| #9 | type Platform = "twitch" | "x" | "kick"; |
| #10 | |
| #11 | interface ChatMessage { |
| #12 | id: string; |
| #13 | platform: Platform; |
| #14 | username: string; |
| #15 | text: string; |
| #16 | timestamp: number; |
| #17 | color: string; |
| #18 | } |
| #19 | |
| #20 | // --------------------------------------------------------------------------- |
| #21 | // Platform metadata |
| #22 | // --------------------------------------------------------------------------- |
| #23 | |
| #24 | const PLATFORM_META: Record<Platform, { label: string; accent: string; bg: string }> = { |
| #25 | twitch: { label: "Twitch", accent: "#9146FF", bg: "rgba(145,70,255,0.12)" }, |
| #26 | x: { label: "X", accent: "#f0f0f0", bg: "rgba(240,240,240,0.08)" }, |
| #27 | kick: { label: "Kick", accent: "#53FC18", bg: "rgba(83,252,24,0.10)" }, |
| #28 | }; |
| #29 | |
| #30 | // --------------------------------------------------------------------------- |
| #31 | // Simulated data pools |
| #32 | // --------------------------------------------------------------------------- |
| #33 | |
| #34 | const TWITCH_USERS = [ |
| #35 | "xqc", "pokimane", "shroud", "summit1g", "ninja", "amouranth", |
| #36 | "hasanabi", "ludwig", "qt_cinderella", "tarik", |
| #37 | ]; |
| #38 | const X_USERS = [ |
| #39 | "elonmusk", "naval", "paulg", "levelsio", "sama", "kaboreth", |
| #40 | "elon_muX", "tokentide", "web3_dev", "cryptofrog", |
| #41 | ]; |
| #42 | const KICK_USERS = [ |
| #43 | "trainwreckstv", "xqcow_kick", "adinross", "amouranth_k", |
| #44 | "bruce", "jason", "destiny", "nickmercs", "gaules", "tfblade", |
| #45 | ]; |
| #46 | |
| #47 | const TWITCH_MSGS = [ |
| #48 | "LETSGOOO", |
| #49 | "poggers that play was insane", |
| #50 | "W take honestly", |
| #51 | "can we get a clip of that??", |
| #52 | "KEKW", |
| #53 | "monkaW what is happening", |
| #54 | "this stream is actually fire tonight", |
| #55 | "gift me a sub pls 🙏", |
| #56 | "LULW imagine losing that", |
| #57 | "holy based", |
| #58 | "chat is moving too fast", |
| #59 | "WICKED", |
| #60 | ]; |
| #61 | |
| #62 | const X_MSGS = [ |
| #63 | "just shipped a new feature, feeling good", |
| #64 | "hot take: most startups fail because they overthink", |
| #65 | "the future of AI is agent-to-agent communication", |
| #66 | "just read the best thread on prompt engineering", |
| #67 | "building in public day 47 🔥", |
| #68 | "unpopular opinion: sleep is a competitive advantage", |
| #69 | "the bear market built different builders", |
| #70 | "🚀 wen moon", |
| #71 | "gm to everyone except people who don't ship", |
| #72 | "engagement farming is an art form tbh", |
| #73 | "just deployed to prod on a friday, pray for me", |
| #74 | ]; |
| #75 | |
| #76 | const KICK_MSGS = [ |
| #77 | "kick chat goes crazy", |
| #78 | "yo this platform is actually smooth", |
| #79 | "W streamer W platform", |
| #80 | "anyone else watching from work? lol", |
| #81 | "7777777777", |
| #82 | "this is the best stream on kick rn", |
| #83 | "bro the latency is so low here", |
| #84 | "GG", |
| #85 | "imagine paying for twitch when kick exists", |
| #86 | "content drought over LFG", |
| #87 | "chat diff honestly", |
| #88 | ]; |
| #89 | |
| #90 | const USER_COLORS = [ |
| #91 | "#FF6B6B", "#4ECDC4", "#FFD93D", "#6C5CE7", "#A8E6CF", |
| #92 | "#FF8A5C", "#F38181", "#AA96DA", "#FCBAD3", "#C3AED6", |
| #93 | "#00B894", "#E17055", "#74B9FF", "#FD79A8", "#FFEAA7", |
| #94 | ]; |
| #95 | |
| #96 | function pick<T>(arr: T[]): T { |
| #97 | return arr[Math.floor(Math.random() * arr.length)]; |
| #98 | } |
| #99 | |
| #100 | let nextId = 0; |
| #101 | function makeMessage(platform: Platform): ChatMessage { |
| #102 | const users = platform === "twitch" ? TWITCH_USERS : platform === "x" ? X_USERS : KICK_USERS; |
| #103 | const msgs = platform === "twitch" ? TWITCH_MSGS : platform === "x" ? X_MSGS : KICK_MSGS; |
| #104 | return { |
| #105 | id: `msg-${nextId++}`, |
| #106 | platform, |
| #107 | username: pick(users), |
| #108 | text: pick(msgs), |
| #109 | timestamp: Date.now(), |
| #110 | color: pick(USER_COLORS), |
| #111 | }; |
| #112 | } |
| #113 | |
| #114 | // Seed initial messages from all three platforms. |
| #115 | function seedMessages(): ChatMessage[] { |
| #116 | const msgs: ChatMessage[] = []; |
| #117 | for (let i = 0; i < 15; i++) { |
| #118 | msgs.push(makeMessage(["twitch", "x", "kick"][i % 3] as Platform)); |
| #119 | } |
| #120 | return msgs.sort((a, b) => a.timestamp - b.timestamp); |
| #121 | } |
| #122 | |
| #123 | // --------------------------------------------------------------------------- |
| #124 | // Components |
| #125 | // --------------------------------------------------------------------------- |
| #126 | |
| #127 | function PlatformBadge({ platform }: { platform: Platform }) { |
| #128 | const meta = PLATFORM_META[platform]; |
| #129 | return ( |
| #130 | <span |
| #131 | className="platform-badge" |
| #132 | style={{ background: meta.bg, color: meta.accent, borderColor: meta.accent + "44" }} |
| #133 | > |
| #134 | {platform === "twitch" && <TwitchIcon />} |
| #135 | {platform === "x" && <XIcon />} |
| #136 | {platform === "kick" && <KickIcon />} |
| #137 | {meta.label} |
| #138 | </span> |
| #139 | ); |
| #140 | } |
| #141 | |
| #142 | function TwitchIcon() { |
| #143 | return ( |
| #144 | <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ marginRight: 4 }}> |
| #145 | <path d="M4 2L2 7v15h6v3h4l3-3h5l6-6V2H4zm17 11l-3 3h-5l-3 3v-3H6V4h15v9z" /> |
| #146 | <path d="M14 7h2v6h-2zM9 7h2v6H9z" /> |
| #147 | </svg> |
| #148 | ); |
| #149 | } |
| #150 | |
| #151 | function XIcon() { |
| #152 | return ( |
| #153 | <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ marginRight: 4 }}> |
| #154 | <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /> |
| #155 | </svg> |
| #156 | ); |
| #157 | } |
| #158 | |
| #159 | function KickIcon() { |
| #160 | return ( |
| #161 | <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ marginRight: 4 }}> |
| #162 | <path d="M3 3h5v10.5L12.5 9h0L18 3h5l-8.5 9L21 21h-5l-5.5-5.5L6 21H1l8.5-9L3 3z" /> |
| #163 | </svg> |
| #164 | ); |
| #165 | } |
| #166 | |
| #167 | function MessageBubble({ msg }: { msg: ChatMessage }) { |
| #168 | const time = new Date(msg.timestamp); |
| #169 | const ts = `${time.getHours().toString().padStart(2, "0")}:${time.getMinutes().toString().padStart(2, "0")}`; |
| #170 | |
| #171 | return ( |
| #172 | <div className="message-row" style={{ animationDelay: "0ms" }}> |
| #173 | <PlatformBadge platform={msg.platform} /> |
| #174 | <span className="msg-username" style={{ color: msg.color }}>{msg.username}</span> |
| #175 | <span className="msg-text">{msg.text}</span> |
| #176 | <span className="msg-time">{ts}</span> |
| #177 | </div> |
| #178 | ); |
| #179 | } |
| #180 | |
| #181 | // --------------------------------------------------------------------------- |
| #182 | // App |
| #183 | // --------------------------------------------------------------------------- |
| #184 | |
| #185 | export default function App() { |
| #186 | const [messages, setMessages] = useState<ChatMessage[]>(seedMessages); |
| #187 | const [filters, setFilters] = useState<Record<Platform, boolean>>({ |
| #188 | twitch: true, |
| #189 | x: true, |
| #190 | kick: true, |
| #191 | }); |
| #192 | const [paused, setPaused] = useState(false); |
| #193 | const [tipOpen, setTipOpen] = useState(false); |
| #194 | const feedRef = useRef<HTMLDivElement>(null); |
| #195 | |
| #196 | // Simulate incoming messages at varying intervals. |
| #197 | useEffect(() => { |
| #198 | if (paused) return; |
| #199 | |
| #200 | const interval = setInterval(() => { |
| #201 | const platform: Platform = pick(["twitch", "x", "kick"]); |
| #202 | setMessages((prev) => { |
| #203 | const next = [...prev, makeMessage(platform)]; |
| #204 | // Keep last 200 messages. |
| #205 | return next.length > 200 ? next.slice(-200) : next; |
| #206 | }); |
| #207 | }, 800 + Math.random() * 1200); |
| #208 | |
| #209 | return () => clearInterval(interval); |
| #210 | }, [paused]); |
| #211 | |
| #212 | // Auto-scroll to bottom. |
| #213 | useEffect(() => { |
| #214 | if (paused) return; |
| #215 | const el = feedRef.current; |
| #216 | if (el) { |
| #217 | el.scrollTop = el.scrollHeight; |
| #218 | } |
| #219 | }, [messages, paused]); |
| #220 | |
| #221 | const toggleFilter = useCallback((p: Platform) => { |
| #222 | setFilters((f) => ({ ...f, [p]: !f[p] })); |
| #223 | }, []); |
| #224 | |
| #225 | const visible = messages.filter((m) => filters[m.platform]); |
| #226 | |
| #227 | const counts = { twitch: 0, x: 0, kick: 0 }; |
| #228 | for (const m of messages) counts[m.platform]++; |
| #229 | |
| #230 | return ( |
| #231 | <div className="app-shell"> |
| #232 | {/* Header */} |
| #233 | <header className="app-header"> |
| #234 | <div className="header-left"> |
| #235 | <div className="logo-mark"> |
| #236 | <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> |
| #237 | <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> |
| #238 | </svg> |
| #239 | </div> |
| #240 | <div> |
| #241 | <h1 className="app-title">Unified Chat</h1> |
| #242 | <p className="app-subtitle">Twitch + X + Kick in one feed</p> |
| #243 | </div> |
| #244 | </div> |
| #245 | |
| #246 | <div className="header-controls"> |
| #247 | {/* Platform filters */} |
| #248 | {(["twitch", "x", "kick"] as Platform[]).map((p) => { |
| #249 | const meta = PLATFORM_META[p]; |
| #250 | return ( |
| #251 | <button |
| #252 | key={p} |
| #253 | className={`filter-btn ${filters[p] ? "active" : ""}`} |
| #254 | style={{ |
| #255 | borderColor: filters[p] ? meta.accent : "transparent", |
| #256 | color: filters[p] ? meta.accent : "rgba(255,255,255,0.35)", |
| #257 | }} |
| #258 | onClick={() => toggleFilter(p)} |
| #259 | > |
| #260 | {p === "twitch" && <TwitchIcon />} |
| #261 | {p === "x" && <XIcon />} |
| #262 | {p === "kick" && <KickIcon />} |
| #263 | {meta.label} |
| #264 | <span className="count-badge">{counts[p]}</span> |
| #265 | </button> |
| #266 | ); |
| #267 | })} |
| #268 | |
| #269 | <button |
| #270 | className={`pause-btn ${paused ? "paused" : ""}`} |
| #271 | onClick={() => setPaused((v) => !v)} |
| #272 | > |
| #273 | {paused ? ( |
| #274 | <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg> |
| #275 | ) : ( |
| #276 | <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z" /></svg> |
| #277 | )} |
| #278 | {paused ? "Resume" : "Pause"} |
| #279 | </button> |
| #280 | |
| #281 | <button className="tip-header-btn" onClick={() => setTipOpen(true)}> |
| #282 | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> |
| #283 | <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/> |
| #284 | </svg> |
| #285 | Tip |
| #286 | </button> |
| #287 | </div> |
| #288 | </header> |
| #289 | |
| #290 | {/* Feed */} |
| #291 | <div className="feed-container" ref={feedRef}> |
| #292 | {visible.map((msg) => ( |
| #293 | <MessageBubble key={msg.id} msg={msg} /> |
| #294 | ))} |
| #295 | {visible.length === 0 && ( |
| #296 | <div className="empty-state">No messages — enable at least one platform.</div> |
| #297 | )} |
| #298 | </div> |
| #299 | |
| #300 | {/* Footer status bar */} |
| #301 | <footer className="status-bar"> |
| #302 | <div className="status-dot-group"> |
| #303 | {(["twitch", "x", "kick"] as Platform[]).map((p) => ( |
| #304 | <span |
| #305 | key={p} |
| #306 | className="status-dot" |
| #307 | style={{ background: filters[p] ? PLATFORM_META[p].accent : "rgba(255,255,255,0.15)" }} |
| #308 | title={PLATFORM_META[p].label} |
| #309 | /> |
| #310 | ))} |
| #311 | </div> |
| #312 | <span className="status-text"> |
| #313 | {visible.length} messages |
| #314 | {paused && <span className="paused-tag">PAUSED</span>} |
| #315 | </span> |
| #316 | <span className="status-text">Live simulation</span> |
| #317 | </footer> |
| #318 | |
| #319 | {/* Tip modal */} |
| #320 | {tipOpen && ( |
| #321 | <Suspense fallback={null}> |
| #322 | <TipPanel onClose={() => setTipOpen(false)} /> |
| #323 | </Suspense> |
| #324 | )} |
| #325 | </div> |
| #326 | ); |
| #327 | } |
| #328 |