diff --git a/apps/android/android/app/build.gradle b/apps/android/android/app/build.gradle index 15dbdab..633a956 100644 --- a/apps/android/android/app/build.gradle +++ b/apps/android/android/app/build.gradle @@ -8,7 +8,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 27 - versionName "1.0.60" + versionName "1.0.70" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/apps/android/package.json b/apps/android/package.json index 1d66597..c9cf358 100644 --- a/apps/android/package.json +++ b/apps/android/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/android", "private": true, - "version": "1.0.60", + "version": "1.0.70", "type": "module", "scripts": { "cap:sync": "npx cap sync", diff --git a/apps/electron/package.json b/apps/electron/package.json index 2079bfa..e385b7e 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/electron", "private": true, - "version": "1.0.60", + "version": "1.0.70", "description": "Brycord - Electron app", "author": "Moyettes", "type": "module", diff --git a/apps/web/package.json b/apps/web/package.json index 9b86024..675e42f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/web", "private": true, - "version": "1.0.60", + "version": "1.0.70", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/platform-web/src/index.js b/packages/platform-web/src/index.js index 578da41..c2d59a6 100644 --- a/packages/platform-web/src/index.js +++ b/packages/platform-web/src/index.js @@ -53,9 +53,18 @@ const webPlatform = { // Detect Android/Capacitor and enable native APK updates if (window.Capacitor?.isNativePlatform?.()) { const YAML_URL = 'https://gitea.moyettes.com/Moyettes/DiscordClone/releases/download/latest/latest-android.yml'; - const AppUpdater = window.Capacitor.Plugins.AppUpdater; + // MainActivity.java's `registerPlugin(AppUpdaterPlugin.class)` puts this + // on `window.Capacitor.Plugins.AppUpdater` at runtime. Guard against + // the plugin being absent in case a future build forgets to register + // it — the whole updater surface then no-ops instead of NPE-ing on + // first invocation. + const AppUpdater = window.Capacitor?.Plugins?.AppUpdater; let apkUrl; + if (!AppUpdater) { + console.warn('[UpdateCheck] AppUpdater plugin not found — updates disabled'); + } + webPlatform.updates = { async checkUpdate() { try { diff --git a/packages/shared/package.json b/packages/shared/package.json index 032b5d5..39ee6bf 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/shared", "private": true, - "version": "1.0.60", + "version": "1.0.70", "type": "module", "main": "src/App.tsx", "dependencies": { diff --git a/packages/shared/src/components/channel/MobileExpressionPickerSheet.module.css b/packages/shared/src/components/channel/MobileExpressionPickerSheet.module.css index 56a2286..1a5c361 100644 --- a/packages/shared/src/components/channel/MobileExpressionPickerSheet.module.css +++ b/packages/shared/src/components/channel/MobileExpressionPickerSheet.module.css @@ -152,6 +152,96 @@ color: var(--text-tertiary); font-size: 14px; font-weight: 500; + text-align: center; + padding: 24px 16px; +} + +/* ── Media tab filter chips ───────────────────────────────────── + Same shape as the desktop EmojiPicker chips so the mobile and + desktop Media surfaces read identically. */ +.filterChips { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 4px 0 0; + flex-shrink: 0; +} + +.filterChip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: transparent; + border: none; + border-radius: var(--radius-md, 0.375rem); + color: var(--text-primary-muted, #a0a3a8); + font: inherit; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.25rem; + cursor: pointer; + transition: background-color 0.12s, color 0.12s; +} + +.filterChip:active { + background-color: var(--background-modifier-hover); + color: var(--text-primary); +} + +.filterChipActive { + background-color: var(--background-modifier-selected); + color: var(--text-primary); +} + +/* ── Saved-media grid (mobile) ────────────────────────────────── + Square thumbnails with the filename ellipsised at the bottom. + Same data shape the desktop picker uses but the cards are a bit + bigger because fingers are less precise than cursors. */ +.mediaGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; + padding: 8px 0; +} + +.mediaCard { + position: relative; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1 / 1; + padding: 0; + border: 1px solid var(--background-modifier-accent); + border-radius: 8px; + background: var(--background-tertiary); + color: var(--text-tertiary); + cursor: pointer; + overflow: hidden; + transition: background-color 0.12s, border-color 0.12s; +} + +.mediaCard:active { + background: var(--background-modifier-hover); + border-color: var(--background-modifier-selected, var(--background-modifier-accent)); +} + +.mediaCardIcon { + color: var(--text-tertiary); +} + +.mediaCardName { + position: absolute; + bottom: 4px; + left: 4px; + right: 4px; + font-size: 10px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; } /* ── Collapsible category sections ─────────────────────────────────── diff --git a/packages/shared/src/components/channel/MobileExpressionPickerSheet.tsx b/packages/shared/src/components/channel/MobileExpressionPickerSheet.tsx index 851aa0d..f71663e 100644 --- a/packages/shared/src/components/channel/MobileExpressionPickerSheet.tsx +++ b/packages/shared/src/components/channel/MobileExpressionPickerSheet.tsx @@ -14,17 +14,23 @@ import { CaretRight, Flag, GameController, + Gif, Heart, + ImageSquare, Leaf, Magnet, MagnifyingGlass, Smiley, + Sticker, X, } from '@phosphor-icons/react'; +import { useQuery } from 'convex/react'; import { BottomSheet } from '@discord-clone/ui'; +import { api } from '../../../../../convex/_generated/api'; import { emojiToCodepoint, getTwemojiUrl } from '../../utils/twemoji'; import emojiData from '@app/data/emojis.json'; import type { EmojiPickerValue } from './EmojiPicker'; +import { GifPicker } from './GifPicker'; import styles from './MobileExpressionPickerSheet.module.css'; interface EmojiEntry { @@ -52,8 +58,8 @@ const CATEGORIES: Array<{ type Tab = 'gifs' | 'media' | 'stickers' | 'emojis'; const TABS: Array<{ id: Tab; label: string; enabled: boolean }> = [ - { id: 'gifs', label: 'GIFs', enabled: false }, - { id: 'media', label: 'Media', enabled: false }, + { id: 'gifs', label: 'GIFs', enabled: true }, + { id: 'media', label: 'Media', enabled: true }, { id: 'stickers', label: 'Stickers', enabled: false }, { id: 'emojis', label: 'Emojis', enabled: true }, ]; @@ -87,6 +93,48 @@ export function MobileExpressionPickerSheet({ const [search, setSearch] = useState(''); const [collapsed, setCollapsed] = useState>(() => new Set()); const [activeCategory, setActiveCategory] = useState('people'); + // Saved-media chip state mirrors the desktop EmojiPicker so + // filter chips land on the same 4 buckets. + const [mediaFilter, setMediaFilter] = useState< + 'all' | 'image' | 'video' | 'audio' + >('all'); + + // Saved library lookup — same shape as the desktop picker uses. + // `skip` when the Media tab is closed so we don't pay for the + // query on first mount. + const myUserId = + typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null; + const savedMedia = + useQuery( + api.savedMedia.list, + myUserId && activeTab === 'media' + ? { userId: myUserId as any } + : 'skip', + ) ?? []; + + const handleRepostSaved = useCallback( + (item: any) => { + // Same custom event the desktop picker dispatches — the + // composer listens for it and feeds the attachment into + // its own send path without re-uploading bytes. + window.dispatchEvent( + new CustomEvent('brycord:repost-saved-media', { + detail: { + url: item.url, + filename: item.filename, + mimeType: item.mimeType, + width: item.width, + height: item.height, + size: item.size, + encryptionKey: item.encryptionKey, + encryptionIv: item.encryptionIv, + }, + }), + ); + onClose(); + }, + [onClose], + ); const searchRef = useRef(null); const tabBarRef = useRef(null); @@ -98,9 +146,17 @@ export function MobileExpressionPickerSheet({ if (isOpen) { setActiveTab(initialTab); setSearch(''); + setMediaFilter('all'); } }, [isOpen, initialTab]); + // Also clear the search box when flipping between tabs so a + // partial emoji search doesn't leak into the Media filename + // filter (or vice versa). + useEffect(() => { + setSearch(''); + }, [activeTab]); + // Focus the search input once the slide-in animation settles. useEffect(() => { if (!isOpen) return; @@ -219,37 +275,162 @@ export function MobileExpressionPickerSheet({ })} -
- - setSearch(e.target.value)} - /> - {search && ( - - )} -
+ {/* GIFs tab has its own internal search bar + categories, + so we hide the sheet's generic search row to avoid a + double search surface. Stickers is still a stub + "Coming Soon" screen, so we hide the search there too. */} + {activeTab !== 'gifs' && activeTab !== 'stickers' && ( +
+ + setSearch(e.target.value)} + /> + {search && ( + + )} +
+ )} + + {/* Filter chips — Media tab only. Matches the desktop + picker's chip row exactly so the mobile surface feels + the same. */} + {activeTab === 'media' && ( +
+ {( + [ + { id: 'all', label: 'All' }, + { id: 'image', label: 'Images' }, + { id: 'video', label: 'Videos' }, + { id: 'audio', label: 'Audio' }, + ] as const + ).map((chip) => { + const isActive = mediaFilter === chip.id; + return ( + + ); + })} +
+ )}
- {activeTab !== 'emojis' ? ( + {activeTab === 'gifs' ? ( + { + onSelect({ kind: 'gif', url }); + onClose(); + }} + /> + ) : activeTab === 'media' ? ( + (() => { + // Filter by chip + filename search, same logic + // as the desktop picker. + const q = search.trim().toLowerCase(); + const filteredSaved = (savedMedia as any[]).filter( + (item) => { + if ( + mediaFilter !== 'all' && + (item.kind ?? '') !== mediaFilter + ) { + return false; + } + if ( + q && + !(item.filename ?? '').toLowerCase().includes(q) + ) { + return false; + } + return true; + }, + ); + if (savedMedia.length === 0) { + return ( +
+ Nothing saved yet. Star an attachment in chat to + bookmark it here for quick re-sharing. +
+ ); + } + if (filteredSaved.length === 0) { + return ( +
+ No saved media match your filter. +
+ ); + } + return ( +
+ {filteredSaved.map((item: any) => { + const isImage = item.kind === 'image'; + const isVideo = item.kind === 'video'; + return ( + + ); + })} +
+ ); + })() + ) : activeTab === 'stickers' ? (
Coming Soon
) : filtered ? (
diff --git a/packages/shared/src/components/layout/AppLayout.tsx b/packages/shared/src/components/layout/AppLayout.tsx index 35f658c..7c08158 100644 --- a/packages/shared/src/components/layout/AppLayout.tsx +++ b/packages/shared/src/components/layout/AppLayout.tsx @@ -12,6 +12,7 @@ import { CreateServerModal } from '../modals/CreateServerModal'; import { PiPOverlay } from '../voice/PiPOverlay'; import { RecordingRecoveryModal } from '../voice/RecordingRecoveryModal'; import { ChannelSettingsModal } from '../channel/ChannelSettingsModal'; +import { UpdateBanner } from './UpdateBanner'; /** * AppLayout — checks session via sessionStorage (matches App.tsx AuthGuard), @@ -162,6 +163,7 @@ export function AppLayout() { /> + ); diff --git a/packages/shared/src/components/layout/UpdateBanner.module.css b/packages/shared/src/components/layout/UpdateBanner.module.css new file mode 100644 index 0000000..fc78368 --- /dev/null +++ b/packages/shared/src/components/layout/UpdateBanner.module.css @@ -0,0 +1,123 @@ +.banner { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + z-index: 9999; + display: flex; + align-items: center; + gap: 12px; + min-width: 320px; + max-width: calc(100vw - 32px); + padding: 12px 14px 12px 16px; + border-radius: 12px; + background: var(--background-secondary, #2b2d31); + border: 1px solid var(--background-modifier-accent, #3f4147); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + color: var(--text-primary, #f2f3f5); + font: inherit; +} + +.iconWrap { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + flex-shrink: 0; + border-radius: 50%; + background: color-mix(in srgb, var(--brand-primary, #5865f2) 18%, transparent); + color: var(--brand-primary, #5865f2); +} + +.body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #f2f3f5); +} + +.subtitle { + font-size: 12px; + color: var(--text-tertiary, #a0a3a8); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.primaryBtn { + padding: 8px 14px; + border: none; + border-radius: 8px; + background: var(--brand-primary, #5865f2); + color: #fff; + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s; +} + +.primaryBtn:hover { + background: var(--brand-primary-hover, #4752c4); +} + +.primaryBtn:disabled { + opacity: 0.7; + cursor: default; +} + +.dismissBtn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--text-tertiary, #a0a3a8); + cursor: pointer; + transition: background-color 0.15s, color 0.15s; +} + +.dismissBtn:hover { + background: var(--background-modifier-hover, rgba(255, 255, 255, 0.06)); + color: var(--text-primary, #f2f3f5); +} + +.progressTrack { + position: absolute; + left: 14px; + right: 14px; + bottom: 6px; + height: 3px; + border-radius: 2px; + background: var(--background-modifier-accent, #3f4147); + overflow: hidden; +} + +.progressFill { + height: 100%; + background: var(--brand-primary, #5865f2); + transition: width 0.2s ease; +} + +.progressRow { + position: relative; + padding-bottom: 10px; +} diff --git a/packages/shared/src/components/layout/UpdateBanner.tsx b/packages/shared/src/components/layout/UpdateBanner.tsx new file mode 100644 index 0000000..9159895 --- /dev/null +++ b/packages/shared/src/components/layout/UpdateBanner.tsx @@ -0,0 +1,139 @@ +import { useEffect, useRef, useState } from 'react'; +import { DownloadSimple, X } from '@phosphor-icons/react'; +import { usePlatform } from '../../platform'; +import styles from './UpdateBanner.module.css'; + +const RELEASE_URL = + 'https://gitea.moyettes.com/Moyettes/DiscordClone/releases/tag/latest'; + +interface UpdateInfo { + updateAvailable: boolean; + updateType?: 'major' | 'minor' | 'patch'; + latestVersion?: string; + currentVersion?: string; + apkUrl?: string; +} + +export function UpdateBanner() { + const platform = usePlatform() as any; + const [info, setInfo] = useState(null); + const [dismissed, setDismissed] = useState(false); + const [installing, setInstalling] = useState(false); + const [progress, setProgress] = useState(null); + const checkedRef = useRef(false); + + useEffect(() => { + if (checkedRef.current) return; + checkedRef.current = true; + if (!platform?.features?.hasNativeUpdates) return; + const checkUpdate = platform?.updates?.checkUpdate; + if (typeof checkUpdate !== 'function') return; + + let cancelled = false; + (async () => { + try { + const result = await checkUpdate(); + if (cancelled) return; + if (result?.updateAvailable) setInfo(result); + } catch (e) { + console.warn('[UpdateBanner] checkUpdate failed', e); + } + })(); + return () => { + cancelled = true; + }; + }, [platform]); + + useEffect(() => { + if (!installing) return; + const listen = platform?.updates?.onDownloadProgress; + if (typeof listen !== 'function') return; + try { + listen((evt: { progress?: number } | number) => { + const raw = typeof evt === 'number' ? evt : evt?.progress; + if (typeof raw === 'number') { + const pct = raw > 1 ? raw : raw * 100; + setProgress(Math.max(0, Math.min(100, pct))); + } + }); + } catch (e) { + console.warn('[UpdateBanner] onDownloadProgress failed', e); + } + }, [installing, platform]); + + if (!platform?.features?.hasNativeUpdates) return null; + if (!info?.updateAvailable || dismissed) return null; + + const handleInstall = async () => { + const install = platform?.updates?.installUpdate; + if (typeof install === 'function') { + try { + setInstalling(true); + setProgress(0); + await install(); + return; + } catch (e) { + console.warn('[UpdateBanner] installUpdate failed, falling back', e); + setInstalling(false); + setProgress(null); + } + } + platform?.links?.openExternal?.(RELEASE_URL); + }; + + const versionLabel = info.latestVersion + ? `v${info.latestVersion}` + : 'latest release'; + const currentLabel = info.currentVersion ? ` (you have v${info.currentVersion})` : ''; + + return ( +
+
+ +
+
+ Update available + + {installing + ? `Downloading ${versionLabel}…${ + progress !== null ? ` ${Math.round(progress)}%` : '' + }` + : `${versionLabel}${currentLabel}`} + +
+
+ + {!installing && ( + + )} +
+ {installing && progress !== null && ( +
+
+
+ )} +
+ ); +} + +export default UpdateBanner;