This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/web",
|
||||
"private": true,
|
||||
"version": "1.0.60",
|
||||
"version": "1.0.70",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 ───────────────────────────────────
|
||||
|
||||
@@ -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<Set<string>>(() => new Set());
|
||||
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 tabBarRef = useRef<HTMLDivElement>(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,6 +275,11 @@ export function MobileExpressionPickerSheet({
|
||||
})}
|
||||
</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}>
|
||||
<MagnifyingGlass
|
||||
size={18}
|
||||
@@ -228,7 +289,11 @@ export function MobileExpressionPickerSheet({
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={styles.searchInput}
|
||||
placeholder="Find the emoji of your dreams"
|
||||
placeholder={
|
||||
activeTab === 'media'
|
||||
? 'Search saved media'
|
||||
: 'Find the emoji of your dreams'
|
||||
}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -243,13 +308,129 @@ export function MobileExpressionPickerSheet({
|
||||
</button>
|
||||
)}
|
||||
</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
|
||||
className={styles.body}
|
||||
ref={bodyRef}
|
||||
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>
|
||||
) : filtered ? (
|
||||
<div className={styles.searchResults}>
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
<PiPOverlay />
|
||||
<RecordingRecoveryModal />
|
||||
<UpdateBanner />
|
||||
</KeybindProvider>
|
||||
</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