feat: pwa
1
.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
29
index.html
@@ -2,20 +2,15 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-light.svg"
|
||||
media="(prefers-color-scheme: light)"
|
||||
type="image/svg+xml"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-dark.svg"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
type="image/svg+xml"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<meta name="apple-mobile-web-app-title" content="Jumble" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
||||
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml">
|
||||
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
|
||||
|
||||
<meta property="og:url" content="https://jumble.social" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Jumble" />
|
||||
@@ -28,6 +23,16 @@
|
||||
content="https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true"
|
||||
/>
|
||||
<title>Jumble</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
3730
package-lock.json
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jumble",
|
||||
"version": "0.1.0",
|
||||
"description": "Yet another Nostr desktop client",
|
||||
"description": "A beautiful nostr client focused on browsing relay feeds",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"author": "codytseng",
|
||||
@@ -70,6 +70,7 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.18.1",
|
||||
"vite": "^6.0.3"
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-pwa": "^0.21.1"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 778 B |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1080 1228" version="1.1" fill="#ffffff" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path id="path1" d="M360.047,1225.75c-31.046,-3.901 -75.11,-14.46 -106.756,-25.58c-101.676,-35.727 -175.164,-93.066 -215.387,-168.055c-12.079,-22.521 -30.071,-71.422 -27.297,-74.195c0.736,-0.736 11.648,5.578 24.249,14.031c135.436,90.86 301.047,169.043 465.056,219.547l32.77,10.091l-20.27,7.416c-43.455,15.896 -105.159,22.678 -152.365,16.745Zm166.293,-59.234c-168.523,-50.004 -331.475,-126.514 -481.755,-226.196c-37.737,-25.031 -41.489,-28.372 -43.419,-38.663c-3.585,-19.109 1.498,-83.894 9.798,-124.886c7.343,-36.266 27.664,-106.034 32.278,-110.818c2.023,-2.099 217.924,48.207 221.274,51.557c0.975,0.975 -1.132,11.339 -4.682,23.032c-24.542,80.842 -27.217,127.586 -9.935,173.593c22.507,59.917 114.521,99.888 177.281,77.012c29.23,-10.654 56.593,-41.085 82.629,-91.894c29.288,-57.155 32.348,-64.988 196.483,-503.076c81.138,-216.562 148.499,-394.821 149.692,-396.131c2.1,-2.304 217.949,76.926 223.076,81.884c2.056,1.988 -262.476,712.505 -307.806,826.747c-18.422,46.426 -56.939,123.045 -77.918,154.993c-10.157,15.469 -30.753,40.901 -45.769,56.515c-27.821,28.93 -66.46,58.952 -75.447,58.621c-2.738,-0.106 -23.339,-5.631 -45.78,-12.29Z" style="fill-rule:nonzero;"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1080 1228" version="1.1" fill="#000000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path id="path1" d="M360.047,1225.75c-31.046,-3.901 -75.11,-14.46 -106.756,-25.58c-101.676,-35.727 -175.164,-93.066 -215.387,-168.055c-12.079,-22.521 -30.071,-71.422 -27.297,-74.195c0.736,-0.736 11.648,5.578 24.249,14.031c135.436,90.86 301.047,169.043 465.056,219.547l32.77,10.091l-20.27,7.416c-43.455,15.896 -105.159,22.678 -152.365,16.745Zm166.293,-59.234c-168.523,-50.004 -331.475,-126.514 -481.755,-226.196c-37.737,-25.031 -41.489,-28.372 -43.419,-38.663c-3.585,-19.109 1.498,-83.894 9.798,-124.886c7.343,-36.266 27.664,-106.034 32.278,-110.818c2.023,-2.099 217.924,48.207 221.274,51.557c0.975,0.975 -1.132,11.339 -4.682,23.032c-24.542,80.842 -27.217,127.586 -9.935,173.593c22.507,59.917 114.521,99.888 177.281,77.012c29.23,-10.654 56.593,-41.085 82.629,-91.894c29.288,-57.155 32.348,-64.988 196.483,-503.076c81.138,-216.562 148.499,-394.821 149.692,-396.131c2.1,-2.304 217.949,76.926 223.076,81.884c2.056,1.988 -262.476,712.505 -307.806,826.747c-18.422,46.426 -56.939,123.045 -77.918,154.993c-10.157,15.469 -30.753,40.901 -45.769,56.515c-27.821,28.93 -66.46,58.952 -75.447,58.621c-2.738,-0.106 -23.339,-5.631 -45.78,-12.29Z" style="fill-rule:nonzero;"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 668 B |
1
public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none"><path fill="#000" d="M209.757 411.798c-7.912-.995-19.141-3.69-27.206-6.527-25.912-9.117-44.641-23.75-54.891-42.886-3.079-5.747-7.664-18.226-6.957-18.934.188-.188 2.969 1.424 6.18 3.581 34.516 23.186 76.721 43.138 118.519 56.026l8.351 2.575-5.166 1.892c-11.074 4.057-26.799 5.788-38.83 4.273Zm42.38-15.115c-42.948-12.761-84.476-32.285-122.775-57.723-9.617-6.388-10.573-7.24-11.065-9.866-.914-4.877.382-21.409 2.497-31.87 1.871-9.255 7.05-27.059 8.226-28.279.516-.536 55.538 12.301 56.391 13.156.249.249-.288 2.894-1.193 5.878-6.254 20.63-6.936 32.558-2.532 44.299 5.736 15.29 29.186 25.49 45.18 19.652 7.449-2.718 14.423-10.484 21.058-23.45 7.464-14.585 8.244-16.584 50.074-128.379 20.677-55.264 37.844-100.754 38.148-101.088.536-.588 55.544 19.63 56.851 20.895.524.508-66.892 181.824-78.444 210.977-4.695 11.847-14.511 31.4-19.857 39.552-2.589 3.948-7.838 10.438-11.664 14.422-7.091 7.383-16.938 15.044-19.228 14.96-.698-.027-5.948-1.437-11.667-3.136Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/pwa-maskable-192x192.png
Normal file
|
After Width: | Height: | Size: 669 B |
BIN
public/pwa-maskable-512x512.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -32,7 +32,7 @@ export default function ProfileButton({
|
||||
if (variant === 'titlebar') {
|
||||
triggerComponent = (
|
||||
<button>
|
||||
<Avatar className="w-7 h-7 hover:opacity-90">
|
||||
<Avatar className="ml-2 w-6 h-6 hover:opacity-90">
|
||||
<AvatarImage src={avatar} />
|
||||
<AvatarFallback>
|
||||
<img src={defaultAvatar} />
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function ScrollToTopButton({
|
||||
<Button
|
||||
variant="secondary-2"
|
||||
className={cn(
|
||||
`absolute bottom-2 right-2 rounded-full w-11 h-11 p-0 hover:text-background transition-transform ${visible ? '' : 'translate-y-14'}`,
|
||||
`absolute bottom-6 right-6 rounded-full w-12 h-12 p-0 hover:text-background transition-transform ${visible ? '' : 'translate-y-20'}`,
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToTop}
|
||||
|
||||
@@ -6,7 +6,6 @@ import AboutInfoDialog from '../AboutInfoDialog'
|
||||
import AccountButton from '../AccountButton'
|
||||
import NotificationButton from '../NotificationButton'
|
||||
import PostButton from '../PostButton'
|
||||
import RefreshButton from '../RefreshButton'
|
||||
import RelaySettingsButton from '../RelaySettingsButton'
|
||||
import SearchButton from '../SearchButton'
|
||||
|
||||
@@ -23,7 +22,6 @@ export default function PrimaryPageSidebar() {
|
||||
<RelaySettingsButton variant="sidebar" />
|
||||
<NotificationButton variant="sidebar" />
|
||||
<SearchButton variant="sidebar" />
|
||||
<RefreshButton variant="sidebar" />
|
||||
<AboutInfoDialog>
|
||||
<Button variant="sidebar" size="sidebar">
|
||||
<Info />
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: hsl(var(--foreground));
|
||||
background-color: hsl(var(--background));
|
||||
overflow: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
text-size-adjust: 100%;
|
||||
|
||||
@@ -2,7 +2,6 @@ import Logo from '@/assets/Logo'
|
||||
import AccountButton from '@/components/AccountButton'
|
||||
import NotificationButton from '@/components/NotificationButton'
|
||||
import PostButton from '@/components/PostButton'
|
||||
import RefreshButton from '@/components/RefreshButton'
|
||||
import RelaySettingsButton from '@/components/RelaySettingsButton'
|
||||
import ScrollToTopButton from '@/components/ScrollToTopButton'
|
||||
import SearchButton from '@/components/SearchButton'
|
||||
@@ -62,7 +61,7 @@ const PrimaryPageLayout = forwardRef(({ children }: { children?: React.ReactNode
|
||||
>
|
||||
<PrimaryPageTitlebar visible={visible} />
|
||||
<div className="sm:px-4 pb-4 pt-11 xl:pt-4">{children}</div>
|
||||
<ScrollToTopButton scrollAreaRef={scrollAreaRef} visible={visible} />
|
||||
<ScrollToTopButton scrollAreaRef={scrollAreaRef} visible={visible && lastScrollTop > 500} />
|
||||
</ScrollArea>
|
||||
)
|
||||
})
|
||||
@@ -107,7 +106,6 @@ function PrimaryPageTitlebar({ visible = true }: { visible?: boolean }) {
|
||||
<SearchButton />
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<RefreshButton />
|
||||
<RelaySettingsButton />
|
||||
<NotificationButton />
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function SecondaryPageLayout({
|
||||
<div className="sm:px-4 pb-4 pt-11 w-full h-full">{children}</div>
|
||||
<ScrollToTopButton
|
||||
scrollAreaRef={scrollAreaRef}
|
||||
visible={!hideScrollToTopButton && visible}
|
||||
visible={!hideScrollToTopButton && visible && lastScrollTop > 500}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)
|
||||
|
||||
@@ -41,20 +41,16 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
|
||||
const [areAlgoRelays, setAreAlgoRelays] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const tempRelays = searchParams
|
||||
.getAll('r')
|
||||
.filter((url) => isWebsocketUrl(url))
|
||||
.map((url) => normalizeUrl(url))
|
||||
if (tempRelays.length) {
|
||||
setTemporaryRelayUrls(tempRelays)
|
||||
}
|
||||
const storedGroups = await storage.getRelayGroups()
|
||||
setRelayGroups(storedGroups)
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const tempRelays = searchParams
|
||||
.getAll('r')
|
||||
.filter((url) => isWebsocketUrl(url))
|
||||
.map((url) => normalizeUrl(url))
|
||||
if (tempRelays.length) {
|
||||
setTemporaryRelayUrls(tempRelays)
|
||||
}
|
||||
|
||||
init()
|
||||
const storedGroups = storage.getRelayGroups()
|
||||
setRelayGroups(storedGroups)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,25 +59,25 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
|
||||
? temporaryRelayUrls
|
||||
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
|
||||
|
||||
if (JSON.stringify(relayUrls) !== JSON.stringify(newRelayUrls)) {
|
||||
setRelayUrls(newRelayUrls)
|
||||
}
|
||||
const relayInfos = await client.fetchRelayInfos(newRelayUrls)
|
||||
setSearchableRelayUrls(newRelayUrls.filter((_, index) => checkSearchRelay(relayInfos[index])))
|
||||
const nonAlgoRelayUrls = newRelayUrls.filter((_, index) => !checkAlgoRelay(relayInfos[index]))
|
||||
setAreAlgoRelays(newRelayUrls.length > 0 && nonAlgoRelayUrls.length === 0)
|
||||
client.setCurrentRelayUrls(nonAlgoRelayUrls)
|
||||
if (JSON.stringify(relayUrls) !== JSON.stringify(newRelayUrls)) {
|
||||
setRelayUrls(newRelayUrls)
|
||||
}
|
||||
}
|
||||
handler()
|
||||
}, [relayGroups, temporaryRelayUrls, relayUrls])
|
||||
|
||||
const updateGroups = async (fn: (pre: TRelayGroup[]) => TRelayGroup[]) => {
|
||||
const updateGroups = (fn: (pre: TRelayGroup[]) => TRelayGroup[]) => {
|
||||
let newGroups = relayGroups
|
||||
setRelayGroups((pre) => {
|
||||
newGroups = fn(pre)
|
||||
return newGroups
|
||||
})
|
||||
await storage.setRelayGroups(newGroups)
|
||||
storage.setRelayGroups(newGroups)
|
||||
}
|
||||
|
||||
const switchRelayGroup = (groupName: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
type TScreenSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
|
||||
@@ -18,8 +18,8 @@ export const useScreenSize = () => {
|
||||
}
|
||||
|
||||
export function ScreenSizeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [screenSize, setScreenSize] = useState<TScreenSize>('xl')
|
||||
const isSmallScreen = screenSize === 'sm'
|
||||
const [screenSize, setScreenSize] = useState<TScreenSize>('sm')
|
||||
const isSmallScreen = useMemo(() => screenSize === 'sm', [screenSize])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
|
||||
@@ -1,13 +1,63 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,png,jpg,svg}'],
|
||||
globDirectory: 'dist/',
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
cleanupOutdatedCaches: true
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true
|
||||
},
|
||||
manifest: {
|
||||
name: 'Jumble',
|
||||
short_name: 'Jumble',
|
||||
icons: [
|
||||
{
|
||||
src: '/pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'any'
|
||||
},
|
||||
{
|
||||
src: '/pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any'
|
||||
},
|
||||
{
|
||||
src: '/pwa-maskable-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable'
|
||||
},
|
||||
{
|
||||
src: '/pwa-maskable-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable'
|
||||
}
|
||||
],
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#FFFFFF',
|
||||
theme_color: '#FFFFFF',
|
||||
description: 'A beautiful nostr client focused on browsing relay feeds'
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||