This commit is contained in:
@@ -8,7 +8,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 27
|
versionCode 27
|
||||||
versionName "1.0.60"
|
versionName "1.0.70"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/android",
|
"name": "@discord-clone/android",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.60",
|
"version": "1.0.70",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"cap:sync": "npx cap sync",
|
"cap:sync": "npx cap sync",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.60",
|
"version": "1.0.70",
|
||||||
"description": "Brycord - Electron app",
|
"description": "Brycord - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/web",
|
"name": "@discord-clone/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.60",
|
"version": "1.0.70",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -53,9 +53,18 @@ const webPlatform = {
|
|||||||
// Detect Android/Capacitor and enable native APK updates
|
// Detect Android/Capacitor and enable native APK updates
|
||||||
if (window.Capacitor?.isNativePlatform?.()) {
|
if (window.Capacitor?.isNativePlatform?.()) {
|
||||||
const YAML_URL = 'https://gitea.moyettes.com/Moyettes/DiscordClone/releases/download/latest/latest-android.yml';
|
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;
|
let apkUrl;
|
||||||
|
|
||||||
|
if (!AppUpdater) {
|
||||||
|
console.warn('[UpdateCheck] AppUpdater plugin not found — updates disabled');
|
||||||
|
}
|
||||||
|
|
||||||
webPlatform.updates = {
|
webPlatform.updates = {
|
||||||
async checkUpdate() {
|
async checkUpdate() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.60",
|
"version": "1.0.70",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.tsx",
|
"main": "src/App.tsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -152,6 +152,96 @@
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
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 ───────────────────────────────────
|
/* ── Collapsible category sections ───────────────────────────────────
|
||||||
|
|||||||
@@ -14,17 +14,23 @@ import {
|
|||||||
CaretRight,
|
CaretRight,
|
||||||
Flag,
|
Flag,
|
||||||
GameController,
|
GameController,
|
||||||
|
Gif,
|
||||||
Heart,
|
Heart,
|
||||||
|
ImageSquare,
|
||||||
Leaf,
|
Leaf,
|
||||||
Magnet,
|
Magnet,
|
||||||
MagnifyingGlass,
|
MagnifyingGlass,
|
||||||
Smiley,
|
Smiley,
|
||||||
|
Sticker,
|
||||||
X,
|
X,
|
||||||
} from '@phosphor-icons/react';
|
} from '@phosphor-icons/react';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
import { BottomSheet } from '@discord-clone/ui';
|
import { BottomSheet } from '@discord-clone/ui';
|
||||||
|
import { api } from '../../../../../convex/_generated/api';
|
||||||
import { emojiToCodepoint, getTwemojiUrl } from '../../utils/twemoji';
|
import { emojiToCodepoint, getTwemojiUrl } from '../../utils/twemoji';
|
||||||
import emojiData from '@app/data/emojis.json';
|
import emojiData from '@app/data/emojis.json';
|
||||||
import type { EmojiPickerValue } from './EmojiPicker';
|
import type { EmojiPickerValue } from './EmojiPicker';
|
||||||
|
import { GifPicker } from './GifPicker';
|
||||||
import styles from './MobileExpressionPickerSheet.module.css';
|
import styles from './MobileExpressionPickerSheet.module.css';
|
||||||
|
|
||||||
interface EmojiEntry {
|
interface EmojiEntry {
|
||||||
@@ -52,8 +58,8 @@ const CATEGORIES: Array<{
|
|||||||
type Tab = 'gifs' | 'media' | 'stickers' | 'emojis';
|
type Tab = 'gifs' | 'media' | 'stickers' | 'emojis';
|
||||||
|
|
||||||
const TABS: Array<{ id: Tab; label: string; enabled: boolean }> = [
|
const TABS: Array<{ id: Tab; label: string; enabled: boolean }> = [
|
||||||
{ id: 'gifs', label: 'GIFs', enabled: false },
|
{ id: 'gifs', label: 'GIFs', enabled: true },
|
||||||
{ id: 'media', label: 'Media', enabled: false },
|
{ id: 'media', label: 'Media', enabled: true },
|
||||||
{ id: 'stickers', label: 'Stickers', enabled: false },
|
{ id: 'stickers', label: 'Stickers', enabled: false },
|
||||||
{ id: 'emojis', label: 'Emojis', enabled: true },
|
{ id: 'emojis', label: 'Emojis', enabled: true },
|
||||||
];
|
];
|
||||||
@@ -87,6 +93,48 @@ export function MobileExpressionPickerSheet({
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||||
const [activeCategory, setActiveCategory] = useState<string>('people');
|
const [activeCategory, setActiveCategory] = useState<string>('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<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
const tabBarRef = useRef<HTMLDivElement>(null);
|
const tabBarRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -98,9 +146,17 @@ export function MobileExpressionPickerSheet({
|
|||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setActiveTab(initialTab);
|
setActiveTab(initialTab);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
|
setMediaFilter('all');
|
||||||
}
|
}
|
||||||
}, [isOpen, initialTab]);
|
}, [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.
|
// Focus the search input once the slide-in animation settles.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@@ -219,6 +275,11 @@ export function MobileExpressionPickerSheet({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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' && (
|
||||||
<div className={styles.searchBar}>
|
<div className={styles.searchBar}>
|
||||||
<MagnifyingGlass
|
<MagnifyingGlass
|
||||||
size={18}
|
size={18}
|
||||||
@@ -228,7 +289,11 @@ export function MobileExpressionPickerSheet({
|
|||||||
<input
|
<input
|
||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
className={styles.searchInput}
|
className={styles.searchInput}
|
||||||
placeholder="Find the emoji of your dreams"
|
placeholder={
|
||||||
|
activeTab === 'media'
|
||||||
|
? 'Search saved media'
|
||||||
|
: 'Find the emoji of your dreams'
|
||||||
|
}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -243,13 +308,129 @@ export function MobileExpressionPickerSheet({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter chips — Media tab only. Matches the desktop
|
||||||
|
picker's chip row exactly so the mobile surface feels
|
||||||
|
the same. */}
|
||||||
|
{activeTab === 'media' && (
|
||||||
|
<div className={styles.filterChips}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ 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 (
|
||||||
|
<button
|
||||||
|
key={chip.id}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.filterChip} ${
|
||||||
|
isActive ? styles.filterChipActive : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => setMediaFilter(chip.id)}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.body}
|
className={styles.body}
|
||||||
ref={bodyRef}
|
ref={bodyRef}
|
||||||
onScroll={handleBodyScroll}
|
onScroll={handleBodyScroll}
|
||||||
>
|
>
|
||||||
{activeTab !== 'emojis' ? (
|
{activeTab === 'gifs' ? (
|
||||||
|
<GifPicker
|
||||||
|
onSelectGif={(url) => {
|
||||||
|
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 (
|
||||||
|
<div className={styles.comingSoon}>
|
||||||
|
Nothing saved yet. Star an attachment in chat to
|
||||||
|
bookmark it here for quick re-sharing.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filteredSaved.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.comingSoon}>
|
||||||
|
No saved media match your filter.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.mediaGrid}>
|
||||||
|
{filteredSaved.map((item: any) => {
|
||||||
|
const isImage = item.kind === 'image';
|
||||||
|
const isVideo = item.kind === 'video';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item._id}
|
||||||
|
type="button"
|
||||||
|
className={styles.mediaCard}
|
||||||
|
title={item.filename}
|
||||||
|
onClick={() => handleRepostSaved(item)}
|
||||||
|
>
|
||||||
|
{isImage ? (
|
||||||
|
<ImageSquare
|
||||||
|
size={28}
|
||||||
|
weight="fill"
|
||||||
|
className={styles.mediaCardIcon}
|
||||||
|
/>
|
||||||
|
) : isVideo ? (
|
||||||
|
<Gif
|
||||||
|
size={28}
|
||||||
|
weight="fill"
|
||||||
|
className={styles.mediaCardIcon}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Sticker
|
||||||
|
size={28}
|
||||||
|
weight="fill"
|
||||||
|
className={styles.mediaCardIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className={styles.mediaCardName}>
|
||||||
|
{item.filename}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : activeTab === 'stickers' ? (
|
||||||
<div className={styles.comingSoon}>Coming Soon</div>
|
<div className={styles.comingSoon}>Coming Soon</div>
|
||||||
) : filtered ? (
|
) : filtered ? (
|
||||||
<div className={styles.searchResults}>
|
<div className={styles.searchResults}>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { CreateServerModal } from '../modals/CreateServerModal';
|
|||||||
import { PiPOverlay } from '../voice/PiPOverlay';
|
import { PiPOverlay } from '../voice/PiPOverlay';
|
||||||
import { RecordingRecoveryModal } from '../voice/RecordingRecoveryModal';
|
import { RecordingRecoveryModal } from '../voice/RecordingRecoveryModal';
|
||||||
import { ChannelSettingsModal } from '../channel/ChannelSettingsModal';
|
import { ChannelSettingsModal } from '../channel/ChannelSettingsModal';
|
||||||
|
import { UpdateBanner } from './UpdateBanner';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppLayout — checks session via sessionStorage (matches App.tsx AuthGuard),
|
* AppLayout — checks session via sessionStorage (matches App.tsx AuthGuard),
|
||||||
@@ -162,6 +163,7 @@ export function AppLayout() {
|
|||||||
/>
|
/>
|
||||||
<PiPOverlay />
|
<PiPOverlay />
|
||||||
<RecordingRecoveryModal />
|
<RecordingRecoveryModal />
|
||||||
|
<UpdateBanner />
|
||||||
</KeybindProvider>
|
</KeybindProvider>
|
||||||
</PresenceProvider>
|
</PresenceProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
123
packages/shared/src/components/layout/UpdateBanner.module.css
Normal file
123
packages/shared/src/components/layout/UpdateBanner.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
139
packages/shared/src/components/layout/UpdateBanner.tsx
Normal file
139
packages/shared/src/components/layout/UpdateBanner.tsx
Normal file
@@ -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<UpdateInfo | null>(null);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<number | null>(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 (
|
||||||
|
<div
|
||||||
|
className={`${styles.banner} ${installing ? styles.progressRow : ''}`}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<div className={styles.iconWrap}>
|
||||||
|
<DownloadSimple size={20} weight="bold" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<span className={styles.title}>Update available</span>
|
||||||
|
<span className={styles.subtitle}>
|
||||||
|
{installing
|
||||||
|
? `Downloading ${versionLabel}…${
|
||||||
|
progress !== null ? ` ${Math.round(progress)}%` : ''
|
||||||
|
}`
|
||||||
|
: `${versionLabel}${currentLabel}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primaryBtn}
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={installing}
|
||||||
|
>
|
||||||
|
{installing ? 'Installing…' : 'Update now'}
|
||||||
|
</button>
|
||||||
|
{!installing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.dismissBtn}
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
aria-label="Dismiss update banner"
|
||||||
|
>
|
||||||
|
<X size={16} weight="bold" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{installing && progress !== null && (
|
||||||
|
<div className={styles.progressTrack}>
|
||||||
|
<div
|
||||||
|
className={styles.progressFill}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdateBanner;
|
||||||
Reference in New Issue
Block a user