1.0.70
All checks were successful
Build and Release / build-and-release (push) Successful in 17m57s

This commit is contained in:
Bryan1029384756
2026-04-14 20:59:21 -05:00
parent 965048f7d2
commit f2dc627b76
11 changed files with 577 additions and 33 deletions

View File

@@ -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 {

View File

@@ -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": {

View File

@@ -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 ───────────────────────────────────

View File

@@ -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,37 +275,162 @@ export function MobileExpressionPickerSheet({
})}
</div>
<div className={styles.searchBar}>
<MagnifyingGlass
size={18}
weight="regular"
className={styles.searchIcon}
/>
<input
ref={searchRef}
className={styles.searchInput}
placeholder="Find the emoji of your dreams"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearch('')}
aria-label="Clear search"
>
<X size={12} weight="bold" />
</button>
)}
</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}
weight="regular"
className={styles.searchIcon}
/>
<input
ref={searchRef}
className={styles.searchInput}
placeholder={
activeTab === 'media'
? 'Search saved media'
: 'Find the emoji of your dreams'
}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearch('')}
aria-label="Clear search"
>
<X size={12} weight="bold" />
</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}>

View File

@@ -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>
);

View 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;
}

View 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;