feat: ship a web app #218
@@ -0,0 +1,72 @@
|
|||||||
|
name: Deploy llink-web
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Target environment"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options: [dev, prod]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-llink-web-${{ inputs.environment }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: js/desktop
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve environment settings
|
||||||
|
id: env
|
||||||
|
run: |
|
||||||
|
case "${{ inputs.environment }}" in
|
||||||
|
dev)
|
||||||
|
echo "project=flowy-dev-440017" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "cluster=cluster" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
prod)
|
||||||
|
echo "project=flowy-prod-440017" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "cluster=prod-cluster" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown environment: ${{ inputs.environment }}" >&2; exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "region=us-west2" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Authenticate to Google Cloud
|
||||||
|
uses: google-github-actions/auth@v2
|
||||||
|
with:
|
||||||
|
credentials_json: ${{ inputs.environment == 'prod' && secrets.PROD_GKE_SERVICE_ACCOUNT_KEY || secrets.DEV_GKE_SERVICE_ACCOUNT_KEY }}
|
||||||
|
|
||||||
|
- uses: google-github-actions/setup-gcloud@v2
|
||||||
|
|
||||||
|
- name: Install gke-gcloud-auth-plugin
|
||||||
|
run: gcloud components install gke-gcloud-auth-plugin --quiet
|
||||||
|
|
||||||
|
- name: Configure Docker for Artifact Registry
|
||||||
|
run: gcloud auth configure-docker us-west2-docker.pkg.dev --quiet
|
||||||
|
|
||||||
|
- name: Get GKE credentials
|
||||||
|
run: |
|
||||||
|
gcloud container clusters get-credentials "${{ steps.env.outputs.cluster }}" \
|
||||||
|
--region "${{ steps.env.outputs.region }}" \
|
||||||
|
--project "${{ steps.env.outputs.project }}"
|
||||||
|
|
||||||
|
- name: Install skaffold
|
||||||
|
run: |
|
||||||
|
curl -fsSLo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64
|
||||||
|
sudo install skaffold /usr/local/bin/
|
||||||
|
skaffold version
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
SKAFFOLD_DEFAULT_REPO: us-west2-docker.pkg.dev/${{ steps.env.outputs.project }}/deployments
|
||||||
|
run: skaffold run -p ${{ inputs.environment }}
|
||||||
+15
-2
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"cloud.google.com/go/firestore"
|
"cloud.google.com/go/firestore"
|
||||||
"cloud.google.com/go/storage"
|
"cloud.google.com/go/storage"
|
||||||
@@ -173,6 +174,9 @@ func main() {
|
|||||||
// Particles
|
// Particles
|
||||||
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
|
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
|
||||||
|
|
||||||
|
// Link metadata
|
||||||
|
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
|
||||||
|
|
||||||
// Depot
|
// Depot
|
||||||
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
|
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
|
||||||
mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload))
|
mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload))
|
||||||
@@ -185,8 +189,17 @@ func main() {
|
|||||||
mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry))
|
mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry))
|
||||||
mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant))
|
mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant))
|
||||||
|
|
||||||
// nil = allow all origins (Electron app needs it).
|
// CORS_ALLOWED_ORIGINS is a comma-separated whitelist for the web client.
|
||||||
muxWithCors := middleware.CORS(nil)(mux)
|
// Empty / unset = allow all
|
||||||
|
var allowedOrigins []string
|
||||||
|
if raw := os.Getenv("CORS_ALLOWED_ORIGINS"); raw != "" {
|
||||||
|
for _, o := range strings.Split(raw, ",") {
|
||||||
|
if o = strings.TrimSpace(o); o != "" {
|
||||||
|
allowedOrigins = append(allowedOrigins, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
muxWithCors := middleware.CORS(allowedOrigins)(mux)
|
||||||
|
|
||||||
addr := fmt.Sprintf("0.0.0.0:%s", port)
|
addr := fmt.Sprintf("0.0.0.0:%s", port)
|
||||||
slog.Info("running server", "addr", addr)
|
slog.Info("running server", "addr", addr)
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LinkMetadata mirrors the TS shape in `js/desktop/src/lib/link-metadata.ts`.
|
||||||
|
// All clients (web and Electron) call this endpoint — the Electron main-process
|
||||||
|
// fetcher was retired so both clients share one parser and the server-side cache.
|
||||||
|
type LinkMetadata struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Image *string `json:"image"`
|
||||||
|
Favicon *string `json:"favicon"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
metadataFetchTimeout = 5 * time.Second
|
||||||
|
metadataMaxBytes = 50 * 1024
|
||||||
|
metadataUserAgent = "Mozilla/5.0 (compatible; llink/1.0)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// In-process cache, unbounded but only successful results are stored.
|
||||||
|
// URL space is bounded in practice.
|
||||||
|
var metadataCache sync.Map // map[string]LinkMetadata
|
||||||
|
|
||||||
|
func (h *Handler) GetLinkMetadata(w http.ResponseWriter, r *http.Request) {
|
||||||
|
raw := r.URL.Query().Get("url")
|
||||||
|
if raw == "" {
|
||||||
|
http.Error(w, "url is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||||
|
http.Error(w, "invalid url", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cached, ok := metadataCache.Load(raw); ok {
|
||||||
|
writeJSON(w, cached)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := guardSSRF(parsed.Hostname()); err != nil {
|
||||||
|
// Treat unresolvable / private hosts as "no metadata" rather than 4xx — the
|
||||||
|
// client treats a null body as a progressive-enhancement miss.
|
||||||
|
writeJSON(w, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := fetchLinkMetadata(r.Context(), parsed)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("link metadata fetch failed", "url", raw, "error", err)
|
||||||
|
writeJSON(w, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataCache.Store(raw, *meta)
|
||||||
|
writeJSON(w, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardSSRF resolves the host and rejects loopback, private, link-local, and
|
||||||
|
// unspecified addresses so the endpoint can't be turned into an internal-network
|
||||||
|
// probe.
|
||||||
|
func guardSSRF(host string) error {
|
||||||
|
ips, err := net.LookupIP(host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return errors.New("no addresses for host")
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||||
|
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
|
||||||
|
return errors.New("private or local address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLinkMetadata(ctx context.Context, target *url.URL) (*LinkMetadata, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, metadataFetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", metadataUserAgent)
|
||||||
|
req.Header.Set("Accept", "text/html")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, errors.New("upstream non-2xx")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, metadataMaxBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
html := string(body)
|
||||||
|
|
||||||
|
domain := strings.TrimPrefix(target.Hostname(), "www.")
|
||||||
|
title := firstNonNil(metaContent(html, "og:title"), parseTitle(html))
|
||||||
|
description := firstNonNil(
|
||||||
|
metaContent(html, "og:description"),
|
||||||
|
metaContent(html, "description"),
|
||||||
|
)
|
||||||
|
image := resolveURL(metaContent(html, "og:image"), target)
|
||||||
|
favicon := parseFavicon(html, target)
|
||||||
|
|
||||||
|
return &LinkMetadata{
|
||||||
|
URL: target.String(),
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Image: image,
|
||||||
|
Favicon: favicon,
|
||||||
|
Domain: domain,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var titleRe = regexp.MustCompile(`(?i)<title[^>]*>([^<]*)</title>`)
|
||||||
|
|
||||||
|
func parseTitle(html string) *string {
|
||||||
|
m := titleRe.FindStringSubmatch(html)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(m[1])
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// metaContent finds <meta property|name="<prop>" content="..."> in either attribute order.
|
||||||
|
func metaContent(html, prop string) *string {
|
||||||
|
escaped := regexp.QuoteMeta(prop)
|
||||||
|
re := regexp.MustCompile(
|
||||||
|
`(?i)<meta[^>]*(?:property|name)=["']` + escaped + `["'][^>]*content=["']([^"']*)["']` +
|
||||||
|
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']` + escaped + `["']`,
|
||||||
|
)
|
||||||
|
m := re.FindStringSubmatch(html)
|
||||||
|
if len(m) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, g := range m[1:] {
|
||||||
|
if g != "" {
|
||||||
|
return &g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
faviconRe1 = regexp.MustCompile(`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']`)
|
||||||
|
faviconRe2 = regexp.MustCompile(`(?i)<link[^>]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseFavicon(html string, base *url.URL) *string {
|
||||||
|
for _, re := range []*regexp.Regexp{faviconRe1, faviconRe2} {
|
||||||
|
if m := re.FindStringSubmatch(html); len(m) >= 2 {
|
||||||
|
return resolveURL(&m[1], base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to /favicon.ico
|
||||||
|
fallback := (&url.URL{Scheme: base.Scheme, Host: base.Host, Path: "/favicon.ico"}).String()
|
||||||
|
return &fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveURL(src *string, base *url.URL) *string {
|
||||||
|
if src == nil || *src == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(*src)
|
||||||
|
if err != nil {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
resolved := base.ResolveReference(parsed).String()
|
||||||
|
return &resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonNil(values ...*string) *string {
|
||||||
|
for _, v := range values {
|
||||||
|
if v != nil && *v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -79,6 +79,9 @@ spec:
|
|||||||
value: "llink://billing/success"
|
value: "llink://billing/success"
|
||||||
- name: "BILLING_CANCEL_URL"
|
- name: "BILLING_CANCEL_URL"
|
||||||
value: "llink://billing/cancel"
|
value: "llink://billing/cancel"
|
||||||
|
# Web client origins
|
||||||
|
- name: "CORS_ALLOWED_ORIGINS"
|
||||||
|
value: "http://localhost:5173,http://localhost:5174,http://localhost:5175,https://llink.dev.flowy.live"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ spec:
|
|||||||
value: "llink://billing/success"
|
value: "llink://billing/success"
|
||||||
- name: "BILLING_CANCEL_URL"
|
- name: "BILLING_CANCEL_URL"
|
||||||
value: "llink://billing/cancel"
|
value: "llink://billing/cancel"
|
||||||
|
# Web client origins
|
||||||
|
- name: "CORS_ALLOWED_ORIGINS"
|
||||||
|
value: "https://llink.flowy.live"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist-web
|
||||||
|
.vite
|
||||||
|
out
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Two-stage build for the llink web SPA. APP_ENV is baked into the bundle
|
||||||
|
# at build time via vite's `define` (see vite.env.ts).
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ARG APP_ENV=prod
|
||||||
|
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN APP_ENV=$APP_ENV yarn web:build:ci
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=builder /app/dist-web /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: llink-web
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: llink-web
|
||||||
|
replicas: 1
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: llink-web
|
||||||
|
spec:
|
||||||
|
serviceAccountName: default-service-account
|
||||||
|
nodeSelector:
|
||||||
|
cloud.google.com/gke-spot: "true"
|
||||||
|
terminationGracePeriodSeconds: 15
|
||||||
|
containers:
|
||||||
|
- name: llink-web
|
||||||
|
image: "llink-web"
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: 20m
|
||||||
|
limits:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: 50m
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 30
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: llink-web
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: llink-web
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
|
protocol: TCP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
kind: HTTPRoute
|
||||||
|
apiVersion: gateway.networking.k8s.io/v1beta1
|
||||||
|
metadata:
|
||||||
|
name: llink-web
|
||||||
|
spec:
|
||||||
|
parentRefs:
|
||||||
|
- kind: Gateway
|
||||||
|
name: external-gateway
|
||||||
|
hostnames:
|
||||||
|
- llink.dev.flowy.live
|
||||||
|
rules:
|
||||||
|
- backendRefs:
|
||||||
|
- name: llink-web
|
||||||
|
port: 80
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
apiVersion: networking.gke.io/v1
|
||||||
|
kind: HealthCheckPolicy
|
||||||
|
metadata:
|
||||||
|
name: llink-web-service-health-check
|
||||||
|
spec:
|
||||||
|
default:
|
||||||
|
checkIntervalSec: 15
|
||||||
|
timeoutSec: 15
|
||||||
|
healthyThreshold: 1
|
||||||
|
unhealthyThreshold: 2
|
||||||
|
logConfig:
|
||||||
|
enabled: true
|
||||||
|
config:
|
||||||
|
type: HTTP
|
||||||
|
httpHealthCheck:
|
||||||
|
portSpecification: USE_FIXED_PORT
|
||||||
|
port: 80
|
||||||
|
requestPath: /health
|
||||||
|
targetRef:
|
||||||
|
group: ""
|
||||||
|
kind: Service
|
||||||
|
name: llink-web
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: llink-web
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: llink-web
|
||||||
|
replicas: 2
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: llink-web
|
||||||
|
spec:
|
||||||
|
serviceAccountName: default-service-account
|
||||||
|
containers:
|
||||||
|
- name: llink-web
|
||||||
|
image: "llink-web"
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: 20m
|
||||||
|
limits:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: 50m
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 30
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: llink-web
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: llink-web
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
|
protocol: TCP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
kind: HTTPRoute
|
||||||
|
apiVersion: gateway.networking.k8s.io/v1beta1
|
||||||
|
metadata:
|
||||||
|
name: llink-web
|
||||||
|
spec:
|
||||||
|
parentRefs:
|
||||||
|
- kind: Gateway
|
||||||
|
name: external-gateway
|
||||||
|
hostnames:
|
||||||
|
- llink.flowy.live
|
||||||
|
rules:
|
||||||
|
- backendRefs:
|
||||||
|
- name: llink-web
|
||||||
|
port: 80
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
apiVersion: networking.gke.io/v1
|
||||||
|
kind: HealthCheckPolicy
|
||||||
|
metadata:
|
||||||
|
name: llink-web-service-health-check
|
||||||
|
spec:
|
||||||
|
default:
|
||||||
|
checkIntervalSec: 15
|
||||||
|
timeoutSec: 15
|
||||||
|
healthyThreshold: 1
|
||||||
|
unhealthyThreshold: 2
|
||||||
|
logConfig:
|
||||||
|
enabled: true
|
||||||
|
config:
|
||||||
|
type: HTTP
|
||||||
|
httpHealthCheck:
|
||||||
|
portSpecification: USE_FIXED_PORT
|
||||||
|
port: 80
|
||||||
|
requestPath: /health
|
||||||
|
targetRef:
|
||||||
|
group: ""
|
||||||
|
kind: Service
|
||||||
|
name: llink-web
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location = /health {
|
||||||
|
access_log off;
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,11 @@
|
|||||||
"publish:mac": "echo '\n⚠️ Have you bumped the version in package.json? (current: '$(node -p \"require('./package.json').version\")') [y/N]' && read -r answer && [ \"$answer\" = \"y\" ] && APP_ENV=prod electron-forge publish --arch=arm64 && APP_ENV=prod electron-forge publish --arch=x64",
|
"publish:mac": "echo '\n⚠️ Have you bumped the version in package.json? (current: '$(node -p \"require('./package.json').version\")') [y/N]' && read -r answer && [ \"$answer\" = \"y\" ] && APP_ENV=prod electron-forge publish --arch=arm64 && APP_ENV=prod electron-forge publish --arch=x64",
|
||||||
"invalidate-gcs-cache": "gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/arm64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/x64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/win32/x64/RELEASES",
|
"invalidate-gcs-cache": "gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/arm64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/x64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/win32/x64/RELEASES",
|
||||||
"lint": "eslint --ext .ts,.tsx .",
|
"lint": "eslint --ext .ts,.tsx .",
|
||||||
"compile": "npx tsc --noEmit 2>&1 | grep '^src/'"
|
"compile": "npx tsc --noEmit 2>&1 | grep '^src/'",
|
||||||
|
"web:dev": "cross-env APP_ENV=dev vite --config vite.web.config.mts",
|
||||||
|
"web:build": "cross-env APP_ENV=prod vite build --config vite.web.config.mts",
|
||||||
|
"web:build:ci": "vite build --config vite.web.config.mts",
|
||||||
|
"web:preview": "vite preview --config vite.web.config.mts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": {
|
"author": {
|
||||||
@@ -43,6 +47,7 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
"@typescript-eslint/parser": "^5.62.0",
|
"@typescript-eslint/parser": "^5.62.0",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"electron": "40.6.0",
|
"electron": "40.6.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
@@ -54,6 +59,7 @@
|
|||||||
"@livekit/components-react": "^2.9.20",
|
"@livekit/components-react": "^2.9.20",
|
||||||
"@livekit/components-styles": "^1.2.0",
|
"@livekit/components-styles": "^1.2.0",
|
||||||
"@sentry/electron": "^7.11.0",
|
"@sentry/electron": "^7.11.0",
|
||||||
|
"@sentry/react": "^10.54.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
apiVersion: skaffold/v4beta11
|
||||||
|
kind: Config
|
||||||
|
metadata:
|
||||||
|
name: llink-web
|
||||||
|
build:
|
||||||
|
local: {}
|
||||||
|
tagPolicy:
|
||||||
|
gitCommit:
|
||||||
|
variant: AbbrevCommitSha
|
||||||
|
profiles:
|
||||||
|
- name: dev
|
||||||
|
build:
|
||||||
|
artifacts:
|
||||||
|
- image: llink-web
|
||||||
|
context: .
|
||||||
|
docker:
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
buildArgs:
|
||||||
|
APP_ENV: dev
|
||||||
|
manifests:
|
||||||
|
rawYaml:
|
||||||
|
- k8s/dev/llink-web.yaml
|
||||||
|
deploy:
|
||||||
|
kubectl: {}
|
||||||
|
- name: prod
|
||||||
|
build:
|
||||||
|
artifacts:
|
||||||
|
- image: llink-web
|
||||||
|
context: .
|
||||||
|
docker:
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
buildArgs:
|
||||||
|
APP_ENV: prod
|
||||||
|
manifests:
|
||||||
|
rawYaml:
|
||||||
|
- k8s/prod/llink-web.yaml
|
||||||
|
deploy:
|
||||||
|
kubectl: {}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { HashRouter, Routes, Route, useNavigate } from "react-router-dom";
|
import { Routes, Route, useNavigate } from "react-router-dom";
|
||||||
|
import { RouterShell } from "@/lib/router-shell";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { LoginPage } from "@/features/auth/login-page";
|
import { LoginPage } from "@/features/auth/login-page";
|
||||||
@@ -19,6 +20,8 @@ import {
|
|||||||
TopLevelErrorBoundary,
|
TopLevelErrorBoundary,
|
||||||
} from "@/components/app-error-boundary";
|
} from "@/components/app-error-boundary";
|
||||||
import { SoundEffectsProvider } from "@/lib/sound-effects/sound-effects-provider";
|
import { SoundEffectsProvider } from "@/lib/sound-effects/sound-effects-provider";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
import { InAppAutoplayCard } from "@/components/in-app-autoplay-card";
|
||||||
|
|
||||||
const queryClient = createQueryClient();
|
const queryClient = createQueryClient();
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ function AutoplayNavigationListener() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return window.electronAutoplay.onNavigate((data) => {
|
return platform.autoplay.onNavigate((data) => {
|
||||||
navigate(`/${data.networkId}/${data.streamId}`);
|
navigate(`/${data.networkId}/${data.streamId}`);
|
||||||
});
|
});
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
@@ -63,8 +66,9 @@ function AutoplayNavigationListener() {
|
|||||||
|
|
||||||
function AuthenticatedApp() {
|
function AuthenticatedApp() {
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<RouterShell>
|
||||||
<AutoplayNavigationListener />
|
<AutoplayNavigationListener />
|
||||||
|
<InAppAutoplayCard />
|
||||||
<RouteErrorBoundary>
|
<RouteErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
@@ -80,7 +84,7 @@ function AuthenticatedApp() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</RouteErrorBoundary>
|
</RouteErrorBoundary>
|
||||||
</HashRouter>
|
</RouterShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
RevokeInvitationRequest,
|
RevokeInvitationRequest,
|
||||||
SignInRequest,
|
SignInRequest,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import type { LinkMetadata } from "@/lib/link-metadata";
|
||||||
|
|
||||||
interface ApiClientConfig {
|
interface ApiClientConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@@ -256,6 +257,16 @@ class ApiClient {
|
|||||||
`/networks/${networkId}/usage`,
|
`/networks/${networkId}/usage`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Link metadata ---
|
||||||
|
|
||||||
|
async getLinkMetadata(url: string): Promise<LinkMetadata | null> {
|
||||||
|
const response = await this.fetch(
|
||||||
|
"GET",
|
||||||
|
`/metadata?url=${encodeURIComponent(url)}`,
|
||||||
|
);
|
||||||
|
return (await response.json()) as LinkMetadata | null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient({
|
export const apiClient = new ApiClient({
|
||||||
|
|||||||
@@ -1,102 +1,43 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { X } from 'lucide-react';
|
import type { AutoplayPayload } from "@/lib/autoplay-ipc";
|
||||||
import type { AutoplayPayload } from '@/lib/autoplay-ipc';
|
import { AutoplayCardContent } from "@/components/autoplay-card-content";
|
||||||
|
|
||||||
export function AutoplayApp() {
|
export function AutoplayApp() {
|
||||||
const [payload, setPayload] = useState<AutoplayPayload | null>(null);
|
const [payload, setPayload] = useState<AutoplayPayload | null>(null);
|
||||||
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return window.electronAutoplay.onPlay((p) => setPayload(p));
|
return window.electronAutoplay.onPlay((p) => setPayload(p));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return window.electronAutoplay.onStop(() => {
|
return window.electronAutoplay.onStop(() => setPayload(null));
|
||||||
mediaRef.current?.pause();
|
|
||||||
setPayload(null);
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
mediaRef.current?.pause();
|
|
||||||
setPayload(null);
|
setPayload(null);
|
||||||
window.electronAutoplay.dismiss();
|
window.electronAutoplay.dismiss();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleNavigate = useCallback(() => {
|
||||||
|
if (!payload) return;
|
||||||
|
window.electronAutoplay.navigate({
|
||||||
|
networkId: payload.networkId,
|
||||||
|
streamId: payload.streamId,
|
||||||
|
});
|
||||||
|
}, [payload]);
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return <div className="h-screen w-screen" />;
|
return <div className="h-screen w-screen" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isVideo = payload.mimeType.startsWith('video/');
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
mediaRef.current?.pause();
|
|
||||||
window.electronAutoplay.navigate({
|
|
||||||
networkId: payload.networkId,
|
|
||||||
streamId: payload.streamId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="h-screen w-screen">
|
||||||
className="relative h-screen w-screen cursor-pointer overflow-hidden bg-black"
|
<AutoplayCardContent
|
||||||
onClick={handleClick}
|
payload={payload}
|
||||||
>
|
onDismiss={handleDismiss}
|
||||||
{isVideo ? (
|
onNavigate={handleNavigate}
|
||||||
<>
|
onEnded={handleDismiss}
|
||||||
<video
|
/>
|
||||||
ref={mediaRef as React.Ref<HTMLVideoElement>}
|
|
||||||
key={payload.particleId}
|
|
||||||
src={payload.downloadUrl}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
onEnded={stop}
|
|
||||||
className="block h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<X className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 bg-gradient-to-t from-black/60 to-transparent px-3 py-2">
|
|
||||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/20 text-[10px] font-medium text-white">
|
|
||||||
{payload.senderInitials}
|
|
||||||
</div>
|
|
||||||
<p className="truncate text-xs text-white/80">{payload.senderName}</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<X className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
<div className="flex h-full w-full items-center gap-2 bg-card px-3 py-3">
|
|
||||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
|
||||||
{payload.senderInitials}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-card-foreground">{payload.senderName}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Playing audio...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<audio
|
|
||||||
ref={mediaRef as React.Ref<HTMLAudioElement>}
|
|
||||||
key={payload.particleId}
|
|
||||||
src={payload.downloadUrl}
|
|
||||||
autoPlay
|
|
||||||
onEnded={stop}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import type { AutoplayPayload } from "@/lib/autoplay-ipc";
|
||||||
|
|
||||||
|
interface AutoplayCardContentProps {
|
||||||
|
payload: AutoplayPayload;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onNavigate: () => void;
|
||||||
|
onEnded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoplayCardContent({
|
||||||
|
payload,
|
||||||
|
onDismiss,
|
||||||
|
onNavigate,
|
||||||
|
onEnded,
|
||||||
|
}: AutoplayCardContentProps) {
|
||||||
|
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
const isVideo = payload.mimeType.startsWith("video/");
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
mediaRef.current?.pause();
|
||||||
|
onNavigate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
mediaRef.current?.pause();
|
||||||
|
onDismiss();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative h-full w-full cursor-pointer overflow-hidden bg-black"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{isVideo ? (
|
||||||
|
<>
|
||||||
|
<video
|
||||||
|
ref={mediaRef as React.Ref<HTMLVideoElement>}
|
||||||
|
key={payload.particleId}
|
||||||
|
src={payload.downloadUrl}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
onEnded={onEnded}
|
||||||
|
className="block h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 bg-gradient-to-t from-black/60 to-transparent px-3 py-2">
|
||||||
|
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/20 text-[10px] font-medium text-white">
|
||||||
|
{payload.senderInitials}
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-xs text-white/80">{payload.senderName}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<div className="flex h-full w-full items-center gap-2 bg-card px-3 py-3">
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||||
|
{payload.senderInitials}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-card-foreground">{payload.senderName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Playing audio...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<audio
|
||||||
|
ref={mediaRef as React.Ref<HTMLAudioElement>}
|
||||||
|
key={payload.particleId}
|
||||||
|
src={payload.downloadUrl}
|
||||||
|
autoPlay
|
||||||
|
onEnded={onEnded}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
import { useAutoplayPayloadStore } from "@/stores/autoplay-payload-store";
|
||||||
|
import { AutoplayCardContent } from "@/components/autoplay-card-content";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-right floating autoplay card used on the web client. The desktop
|
||||||
|
* client uses a separate BrowserWindow (`src/autoplay_window/`), so this
|
||||||
|
* component renders nothing on Electron.
|
||||||
|
*/
|
||||||
|
export function InAppAutoplayCard() {
|
||||||
|
const payload = useAutoplayPayloadStore((s) => s.payload);
|
||||||
|
const setPayload = useAutoplayPayloadStore((s) => s.setPayload);
|
||||||
|
const setPendingNav = useAutoplayPayloadStore((s) => s.setPendingNav);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => setPayload(null), [setPayload]);
|
||||||
|
|
||||||
|
const handleNavigate = useCallback(() => {
|
||||||
|
if (!payload) return;
|
||||||
|
setPayload(null);
|
||||||
|
setPendingNav({ networkId: payload.networkId, streamId: payload.streamId });
|
||||||
|
}, [payload, setPayload, setPendingNav]);
|
||||||
|
|
||||||
|
if (platform.kind !== "web") return null;
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
const isVideo = payload.mimeType.startsWith("video/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-4 right-4 z-50 w-80 overflow-hidden rounded-lg shadow-lg"
|
||||||
|
style={{ height: isVideo ? 180 : 64 }}
|
||||||
|
>
|
||||||
|
<AutoplayCardContent
|
||||||
|
payload={payload}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onEnded={handleDismiss}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { LinkMetadata } from "@/lib/link-metadata";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
|
||||||
interface LinkPreviewCardProps {
|
interface LinkPreviewCardProps {
|
||||||
metadata: LinkMetadata;
|
metadata: LinkMetadata;
|
||||||
@@ -12,7 +13,7 @@ interface LinkPreviewCardProps {
|
|||||||
export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||||
const handleOpen = (e: React.MouseEvent) => {
|
const handleOpen = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
window.electronLink.openExternal(metadata.url);
|
platform.link.openExternal(metadata.url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = (e: React.MouseEvent) => {
|
const handleCopy = (e: React.MouseEvent) => {
|
||||||
|
|||||||
@@ -3,20 +3,23 @@ import { Copy, Minus, Square, X } from "lucide-react";
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
|
||||||
export function WindowControls() {
|
export function WindowControls() {
|
||||||
const [isMaximized, setIsMaximized] = useState(false);
|
const [isMaximized, setIsMaximized] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return window.electronWindow.onMaximizeChange(setIsMaximized);
|
return platform.window.onMaximizeChange(setIsMaximized);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (platform.kind !== "electron") return null;
|
||||||
|
|
||||||
const minimize = (
|
const minimize = (
|
||||||
<Button
|
<Button
|
||||||
key="minimize"
|
key="minimize"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => window.electronWindow.minimize()}
|
onClick={() => platform.window.minimize()}
|
||||||
aria-label="Minimize"
|
aria-label="Minimize"
|
||||||
className="dark:hover:bg-white/10 rounded text-white/50"
|
className="dark:hover:bg-white/10 rounded text-white/50"
|
||||||
>
|
>
|
||||||
@@ -29,7 +32,7 @@ export function WindowControls() {
|
|||||||
key="maximize"
|
key="maximize"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => window.electronWindow.maximize()}
|
onClick={() => platform.window.maximize()}
|
||||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||||
className="dark:hover:bg-white/10 rounded text-white/50"
|
className="dark:hover:bg-white/10 rounded text-white/50"
|
||||||
>
|
>
|
||||||
@@ -42,7 +45,7 @@ export function WindowControls() {
|
|||||||
key="close"
|
key="close"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => window.electronWindow.close()}
|
onClick={() => platform.window.close()}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
className="hover:text-destructive dark:hover:bg-white/10 rounded text-white/50"
|
className="hover:text-destructive dark:hover:bg-white/10 rounded text-white/50"
|
||||||
>
|
>
|
||||||
|
|||||||
Vendored
-2
@@ -1,4 +1,3 @@
|
|||||||
import type { LinkMetadata } from './lib/link-metadata';
|
|
||||||
import type { AutoplayPayload } from './lib/autoplay-ipc';
|
import type { AutoplayPayload } from './lib/autoplay-ipc';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -42,7 +41,6 @@ declare global {
|
|||||||
onInit: (callback: () => void) => () => void;
|
onInit: (callback: () => void) => () => void;
|
||||||
};
|
};
|
||||||
electronLink: {
|
electronLink: {
|
||||||
fetchMetadata: (url: string) => Promise<LinkMetadata | null>;
|
|
||||||
openExternal: (url: string) => Promise<void>;
|
openExternal: (url: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
electronAttachment: {
|
electronAttachment: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
|
||||||
export interface AttachmentItem {
|
export interface AttachmentItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -95,7 +96,7 @@ export function AttachmentLightbox({
|
|||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
if (!current || !url || current.source.kind !== "remote") return;
|
if (!current || !url || current.source.kind !== "remote") return;
|
||||||
window.electronAttachment.download(url, current.filename);
|
platform.attachment.download(url, current.filename);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = () => {
|
const handleRemove = () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { H3, Muted } from "@/components/ui/typography";
|
import { H3, Muted } from "@/components/ui/typography";
|
||||||
import { PRIVACY_URL, TERMS_URL } from "@/lib/constants";
|
import { PRIVACY_URL, TERMS_URL } from "@/lib/constants";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
|
||||||
interface EmailStepProps {
|
interface EmailStepProps {
|
||||||
onCodeSent: (email: string) => void;
|
onCodeSent: (email: string) => void;
|
||||||
@@ -62,7 +63,7 @@ export function EmailStep({ onCodeSent }: EmailStepProps) {
|
|||||||
By continuing, you agree to our{" "}
|
By continuing, you agree to our{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => window.electronLink.openExternal(TERMS_URL)}
|
onClick={() => platform.link.openExternal(TERMS_URL)}
|
||||||
className="underline underline-offset-2 hover:text-foreground"
|
className="underline underline-offset-2 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Terms of Service
|
Terms of Service
|
||||||
@@ -70,7 +71,7 @@ export function EmailStep({ onCodeSent }: EmailStepProps) {
|
|||||||
and{" "}
|
and{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => window.electronLink.openExternal(PRIVACY_URL)}
|
onClick={() => platform.link.openExternal(PRIVACY_URL)}
|
||||||
className="underline underline-offset-2 hover:text-foreground"
|
className="underline underline-offset-2 hover:text-foreground"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getAttachmentHandler,
|
getAttachmentHandler,
|
||||||
type AttachmentItem,
|
type AttachmentItem,
|
||||||
} from "@/features/attachments/attachment-lightbox";
|
} from "@/features/attachments/attachment-lightbox";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
|
||||||
export interface PendingAttachment {
|
export interface PendingAttachment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -120,7 +121,7 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => window.electronLink.openExternal(metadata.url)}
|
onClick={() => platform.link.openExternal(metadata.url)}
|
||||||
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15"
|
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 text-[10px] text-white/40">
|
<div className="flex items-center gap-1 text-[10px] text-white/40">
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
|
|||||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||||
import { useComposeIntentStore } from "@/stores/compose-intent-store";
|
import { useComposeIntentStore } from "@/stores/compose-intent-store";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
import { requireDesktop } from "@/lib/platform/desktop-only";
|
||||||
|
|
||||||
export type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
|
export type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
|
||||||
|
|
||||||
@@ -512,6 +514,7 @@ export function ComposeOverlay({
|
|||||||
} else if (e.key === "s" || e.key === "S") {
|
} else if (e.key === "s" || e.key === "S") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!guardIdle()) break;
|
if (!guardIdle()) break;
|
||||||
|
if (!requireDesktop("Screen recording")) break;
|
||||||
setRecordingSource("screen");
|
setRecordingSource("screen");
|
||||||
setStepSync("picking");
|
setStepSync("picking");
|
||||||
} else if (e.key === "t" || e.key === "T") {
|
} else if (e.key === "t" || e.key === "T") {
|
||||||
@@ -594,7 +597,7 @@ export function ComposeOverlay({
|
|||||||
<ScreenSourcePicker
|
<ScreenSourcePicker
|
||||||
title="Record your screen"
|
title="Record your screen"
|
||||||
confirmLabel="Record"
|
confirmLabel="Record"
|
||||||
getSources={window.electronScreen.getScreenSources}
|
getSources={platform.screenRecord.getScreenSources}
|
||||||
onSelect={handleScreenSourceSelected}
|
onSelect={handleScreenSourceSelected}
|
||||||
onCancel={cancel}
|
onCancel={cancel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
import { requireDesktop } from "@/lib/platform/desktop-only";
|
||||||
|
|
||||||
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
||||||
const VIDEO_FALLBACK_MIME = "video/webm";
|
const VIDEO_FALLBACK_MIME = "video/webm";
|
||||||
@@ -75,6 +77,7 @@ export function useScreenRecorder({
|
|||||||
|
|
||||||
const startRecording = useCallback(
|
const startRecording = useCallback(
|
||||||
async (sourceId: string) => {
|
async (sourceId: string) => {
|
||||||
|
if (!requireDesktop("Screen recording")) return;
|
||||||
try {
|
try {
|
||||||
// 1. Screen video
|
// 1. Screen video
|
||||||
const screenStream = await navigator.mediaDevices.getUserMedia({
|
const screenStream = await navigator.mediaDevices.getUserMedia({
|
||||||
@@ -113,7 +116,7 @@ export function useScreenRecorder({
|
|||||||
const durationMs = Date.now() - startTimeRef.current;
|
const durationMs = Date.now() - startTimeRef.current;
|
||||||
const blob = new Blob(chunksRef.current, { type: mime });
|
const blob = new Blob(chunksRef.current, { type: mime });
|
||||||
stopAllTracks();
|
stopAllTracks();
|
||||||
window.electronScreen.stopRecordingWindow();
|
platform.screenRecord.cancel();
|
||||||
|
|
||||||
if (blob.size > 0) {
|
if (blob.size > 0) {
|
||||||
onFinishRef.current(blob, durationMs, mime);
|
onFinishRef.current(blob, durationMs, mime);
|
||||||
@@ -123,17 +126,17 @@ export function useScreenRecorder({
|
|||||||
recorder.start(1000);
|
recorder.start(1000);
|
||||||
|
|
||||||
// 4. Show floating control window
|
// 4. Show floating control window
|
||||||
window.electronScreen.startRecordingWindow();
|
platform.screenRecord.start();
|
||||||
|
|
||||||
// 5. Listen for stop from floating window
|
// 5. Listen for stop from floating window
|
||||||
cleanupIpcRef.current = window.electronScreen.onStopRequested(() => {
|
cleanupIpcRef.current = platform.screenRecord.onStopRequested(() => {
|
||||||
if (recorderRef.current?.state === "recording") {
|
if (recorderRef.current?.state === "recording") {
|
||||||
recorderRef.current.stop();
|
recorderRef.current.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
stopAllTracks();
|
stopAllTracks();
|
||||||
window.electronScreen.stopRecordingWindow();
|
platform.screenRecord.cancel();
|
||||||
onErrorRef.current(
|
onErrorRef.current(
|
||||||
err instanceof Error ? err.message : "Failed to start screen recording",
|
err instanceof Error ? err.message : "Failed to start screen recording",
|
||||||
);
|
);
|
||||||
@@ -157,14 +160,14 @@ export function useScreenRecorder({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
stopAllTracks();
|
stopAllTracks();
|
||||||
window.electronScreen.stopRecordingWindow();
|
platform.screenRecord.cancel();
|
||||||
}, [stopAllTracks]);
|
}, [stopAllTracks]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
stopAllTracks();
|
stopAllTracks();
|
||||||
window.electronScreen.stopRecordingWindow();
|
platform.screenRecord.cancel();
|
||||||
};
|
};
|
||||||
}, [stopAllTracks]);
|
}, [stopAllTracks]);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { useNetworkUsage } from "@/hooks/use-network-usage";
|
import { useNetworkUsage } from "@/hooks/use-network-usage";
|
||||||
import { useIsNetworkAdmin } from "@/hooks/use-networks";
|
import { useIsNetworkAdmin } from "@/hooks/use-networks";
|
||||||
import type { BillingCadence, BillingStatus } from "@/api/types";
|
import type { BillingCadence, BillingStatus } from "@/api/types";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
|
||||||
function formatCents(cents: number): string {
|
function formatCents(cents: number): string {
|
||||||
if (cents % 100 === 0) return `$${cents / 100}`;
|
if (cents % 100 === 0) return `$${cents / 100}`;
|
||||||
@@ -171,7 +172,7 @@ function FreeBilling({
|
|||||||
|
|
||||||
const handleUpgrade = () => {
|
const handleUpgrade = () => {
|
||||||
createCheckout.mutate(cadence, {
|
createCheckout.mutate(cadence, {
|
||||||
onSuccess: ({ url }) => window.electronLink.openExternal(url),
|
onSuccess: ({ url }) => platform.link.openExternal(url),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -228,7 +229,7 @@ function ProBilling({
|
|||||||
|
|
||||||
const handleManage = () => {
|
const handleManage = () => {
|
||||||
createPortal.mutate(undefined, {
|
createPortal.mutate(undefined, {
|
||||||
onSuccess: ({ url }) => window.electronLink.openExternal(url),
|
onSuccess: ({ url }) => platform.link.openExternal(url),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getAttachmentHandler,
|
getAttachmentHandler,
|
||||||
type AttachmentItem,
|
type AttachmentItem,
|
||||||
} from "@/features/attachments/attachment-lightbox";
|
} from "@/features/attachments/attachment-lightbox";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
|
||||||
type FileParticle = Extract<Particle, { type: "file" }>;
|
type FileParticle = Extract<Particle, { type: "file" }>;
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ function openParticle(
|
|||||||
if (getAttachmentHandler(particle.properties.mime_type) === "lightbox") {
|
if (getAttachmentHandler(particle.properties.mime_type) === "lightbox") {
|
||||||
onPreview(index);
|
onPreview(index);
|
||||||
} else if (url) {
|
} else if (url) {
|
||||||
window.electronLink.openExternal(url);
|
platform.link.openExternal(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ function ImageAttachment({
|
|||||||
|
|
||||||
const handleDownload = (e: React.MouseEvent) => {
|
const handleDownload = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
window.electronAttachment.download(url, particle.properties.filename);
|
platform.attachment.download(url, particle.properties.filename);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -113,7 +114,7 @@ function FileAttachment({
|
|||||||
const handleDownload = (e: React.MouseEvent) => {
|
const handleDownload = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
window.electronAttachment.download(url, particle.properties.filename);
|
platform.attachment.download(url, particle.properties.filename);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { WindowControls } from "@/components/window-controls";
|
|||||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||||
import { useStreamPresence } from "@/features/particles/stream-presence-context";
|
import { useStreamPresence } from "@/features/particles/stream-presence-context";
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
import { requireDesktop } from "@/lib/platform/desktop-only";
|
||||||
|
|
||||||
function getParticleDisplayName(particle: Particle): string {
|
function getParticleDisplayName(particle: Particle): string {
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
@@ -71,8 +73,9 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
|||||||
const hasActiveHuddle = huddleParticipants.length > 0;
|
const hasActiveHuddle = huddleParticipants.length > 0;
|
||||||
|
|
||||||
const handleJoinHuddle = () => {
|
const handleJoinHuddle = () => {
|
||||||
|
if (!requireDesktop("Huddle")) return;
|
||||||
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
||||||
window.electronWindow.openHuddle({ token, serverUrl: server_url });
|
platform.huddle.open({ token, serverUrl: server_url });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ import { usePlaybackPauseStore, selectIsPaused } from "@/stores/playback-pause-s
|
|||||||
import { usePlaybackKeys } from "@/hooks/use-playback-keys";
|
import { usePlaybackKeys } from "@/hooks/use-playback-keys";
|
||||||
import { useStreamNavigationKeys } from "@/hooks/use-stream-navigation-keys";
|
import { useStreamNavigationKeys } from "@/hooks/use-stream-navigation-keys";
|
||||||
import { useStreamActionKeys } from "@/hooks/use-stream-action-keys";
|
import { useStreamActionKeys } from "@/hooks/use-stream-action-keys";
|
||||||
import { c } from "vite/dist/node/types.d-aGj9QkWt";
|
import { platform } from "@/lib/platform";
|
||||||
|
import { requireDesktop } from "@/lib/platform/desktop-only";
|
||||||
|
|
||||||
function getReactions(particle: Particle): Record<string, string[]> | undefined {
|
function getReactions(particle: Particle): Record<string, string[]> | undefined {
|
||||||
if (isParticleDeleted(particle)) return undefined;
|
if (isParticleDeleted(particle)) return undefined;
|
||||||
@@ -150,7 +151,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useMount(() => {
|
useMount(() => {
|
||||||
window.electronAutoplay.dismiss();
|
platform.autoplay.dismiss();
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -220,8 +221,9 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenHuddle = useCallback(() => {
|
const handleOpenHuddle = useCallback(() => {
|
||||||
|
if (!requireDesktop("Huddle")) return;
|
||||||
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
||||||
window.electronWindow.openHuddle({ token, serverUrl: server_url });
|
platform.huddle.open({ token, serverUrl: server_url });
|
||||||
});
|
});
|
||||||
navigate(`/${networkId}`);
|
navigate(`/${networkId}`);
|
||||||
}, [networkId, streamParticle.id, navigate]);
|
}, [networkId, streamParticle.id, navigate]);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { logError, toUserMessage } from "@/lib/errors";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { PRIVACY_URL, SUPPORT_EMAIL, TERMS_URL } from "@/lib/constants";
|
import { PRIVACY_URL, SUPPORT_EMAIL, TERMS_URL } from "@/lib/constants";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
|
||||||
interface SettingsRowProps {
|
interface SettingsRowProps {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
@@ -79,7 +80,7 @@ export default function SettingsPage() {
|
|||||||
const [version, setVersion] = useState<string>();
|
const [version, setVersion] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.electronApp.getVersion().then(setVersion);
|
platform.app.getVersion().then(setVersion);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleEmailNotifications = async (checked: boolean) => {
|
const handleToggleEmailNotifications = async (checked: boolean) => {
|
||||||
@@ -190,12 +191,12 @@ export default function SettingsPage() {
|
|||||||
<SettingsRow
|
<SettingsRow
|
||||||
icon={<Shield className="size-4" />}
|
icon={<Shield className="size-4" />}
|
||||||
label="Privacy Policy"
|
label="Privacy Policy"
|
||||||
onClick={() => window.electronLink.openExternal(PRIVACY_URL)}
|
onClick={() => platform.link.openExternal(PRIVACY_URL)}
|
||||||
/>
|
/>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
icon={<FileText className="size-4" />}
|
icon={<FileText className="size-4" />}
|
||||||
label="Terms of Service"
|
label="Terms of Service"
|
||||||
onClick={() => window.electronLink.openExternal(TERMS_URL)}
|
onClick={() => platform.link.openExternal(TERMS_URL)}
|
||||||
/>
|
/>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useLiveParticleChildren } from "@/hooks/use-particle";
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { particlePath } from "@/lib/particle-path";
|
import { particlePath } from "@/lib/particle-path";
|
||||||
import type { Particle, StreamProperties } from "@/api/types";
|
import type { Particle, StreamProperties } from "@/api/types";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
|
||||||
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
|
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export function useDockBadge(networkId: string | undefined) {
|
|||||||
}, [children, userId]);
|
}, [children, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.electronApp.setDockBadge(unseenCount);
|
platform.app.setDockBadge(unseenCount);
|
||||||
return () => window.electronApp.setDockBadge(0);
|
return () => platform.app.setDockBadge(0);
|
||||||
}, [unseenCount]);
|
}, [unseenCount]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||||
import { extractUrls, type LinkMetadata } from "@/lib/link-metadata";
|
import { extractUrls, type LinkMetadata } from "@/lib/link-metadata";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
|
||||||
export function useLinkMetadata(url: string | null) {
|
export function useLinkMetadata(url: string | null) {
|
||||||
return useQuery<LinkMetadata | null>({
|
return useQuery<LinkMetadata | null>({
|
||||||
queryKey: ["link-metadata", url],
|
queryKey: ["link-metadata", url],
|
||||||
queryFn: () => window.electronLink.fetchMetadata(url!),
|
queryFn: () => platform.link.fetchMetadata(url!),
|
||||||
enabled: !!url,
|
enabled: !!url,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
gcTime: 30 * 60 * 1000,
|
gcTime: 30 * 60 * 1000,
|
||||||
@@ -30,7 +31,7 @@ export function useAllLinkMetadata(text: string): LinkPreviewEntry[] {
|
|||||||
const results = useQueries({
|
const results = useQueries({
|
||||||
queries: urls.map((url) => ({
|
queries: urls.map((url) => ({
|
||||||
queryKey: ["link-metadata", url],
|
queryKey: ["link-metadata", url],
|
||||||
queryFn: () => window.electronLink.fetchMetadata(url),
|
queryFn: () => platform.link.fetchMetadata(url),
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
gcTime: 30 * 60 * 1000,
|
gcTime: 30 * 60 * 1000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
|||||||
import { useAutoplayStore } from "@/stores/autoplay-store";
|
import { useAutoplayStore } from "@/stores/autoplay-store";
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
import { logError } from "@/lib/errors";
|
import { logError } from "@/lib/errors";
|
||||||
|
import { platform } from "@/lib/platform";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers autoplay when a stream's latest child changes to a new media particle.
|
* Triggers autoplay when a stream's latest child changes to a new media particle.
|
||||||
@@ -55,7 +56,7 @@ export function useStreamAutoplay(
|
|||||||
);
|
);
|
||||||
|
|
||||||
apiClient.getParticleDownloadUrl(particle.properties.object_id).then((downloadUrl) => {
|
apiClient.getParticleDownloadUrl(particle.properties.object_id).then((downloadUrl) => {
|
||||||
window.electronAutoplay.play({
|
platform.autoplay.play({
|
||||||
particleId: particle.id,
|
particleId: particle.id,
|
||||||
streamId: streamParticle.id,
|
streamId: streamParticle.id,
|
||||||
networkId,
|
networkId,
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export const isMac = window.electronWindow?.platform === "darwin";
|
|
||||||
|
|
||||||
// Symbol to show in keyboard hints for the primary modifier
|
|
||||||
// (Cmd on macOS, Ctrl on Windows/Linux).
|
|
||||||
export const metaKey = isMac ? "⌘" : "Ctrl";
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
|
import { platform, DESKTOP_DOWNLOAD_URL } from "@/lib/platform";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate desktop-only features (huddle, screen recording). On Electron this
|
||||||
|
* always returns true. On web it shows a toast linking to the desktop app
|
||||||
|
* download and returns false — callers should bail.
|
||||||
|
*/
|
||||||
|
export function requireDesktop(feature: string): boolean {
|
||||||
|
if (platform.kind === "electron") return true;
|
||||||
|
toast.message(`${feature} is only available in the desktop app`, {
|
||||||
|
action: {
|
||||||
|
label: "Download",
|
||||||
|
onClick: () => window.open(DESKTOP_DOWNLOAD_URL, "_blank", "noopener,noreferrer"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import type { Platform } from "./types";
|
||||||
|
|
||||||
|
export const electronPlatform: Platform = {
|
||||||
|
kind: "electron",
|
||||||
|
|
||||||
|
window: {
|
||||||
|
minimize: () => window.electronWindow.minimize(),
|
||||||
|
maximize: () => window.electronWindow.maximize(),
|
||||||
|
fullscreen: () => window.electronWindow.fullscreen(),
|
||||||
|
close: () => window.electronWindow.close(),
|
||||||
|
onMaximizeChange: (cb) => window.electronWindow.onMaximizeChange(cb),
|
||||||
|
get platform() {
|
||||||
|
const p = window.electronWindow?.platform;
|
||||||
|
return p === "darwin" || p === "win32" || p === "linux" ? p : "linux";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
huddle: {
|
||||||
|
isSupported: true,
|
||||||
|
open: (args) => window.electronWindow.openHuddle(args),
|
||||||
|
close: () => window.electronWindow.closeHuddle(),
|
||||||
|
getScreenSources: () => window.electronHuddle.getScreenSources(),
|
||||||
|
},
|
||||||
|
|
||||||
|
screenRecord: {
|
||||||
|
isSupported: true,
|
||||||
|
start: () => window.electronScreen.startRecordingWindow(),
|
||||||
|
stop: () => window.electronScreenRecord.stop(),
|
||||||
|
cancel: () => window.electronScreen.stopRecordingWindow(),
|
||||||
|
onStopRequested: (cb) => window.electronScreen.onStopRequested(cb),
|
||||||
|
getScreenSources: () => window.electronScreen.getScreenSources(),
|
||||||
|
},
|
||||||
|
|
||||||
|
autoplay: {
|
||||||
|
play: (payload) => window.electronAutoplay.play(payload),
|
||||||
|
dismiss: () => window.electronAutoplay.dismiss(),
|
||||||
|
navigate: (d) => window.electronAutoplay.navigate(d),
|
||||||
|
onPlay: (cb) => window.electronAutoplay.onPlay(cb),
|
||||||
|
onStop: (cb) => window.electronAutoplay.onStop(cb),
|
||||||
|
onNavigate: (cb) => window.electronAutoplay.onNavigate(cb),
|
||||||
|
},
|
||||||
|
|
||||||
|
link: {
|
||||||
|
fetchMetadata: (url) => apiClient.getLinkMetadata(url).catch(() => null),
|
||||||
|
openExternal: (url) => window.electronLink.openExternal(url),
|
||||||
|
},
|
||||||
|
|
||||||
|
attachment: {
|
||||||
|
download: (url, filename) => window.electronAttachment.download(url, filename),
|
||||||
|
},
|
||||||
|
|
||||||
|
app: {
|
||||||
|
setDockBadge: (count) => window.electronApp.setDockBadge(count),
|
||||||
|
getVersion: () => window.electronApp.getVersion(),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { electronPlatform } from "./electron";
|
||||||
|
|
||||||
|
export const platform = electronPlatform;
|
||||||
|
export type { Platform, ScreenSource } from "./types";
|
||||||
|
export { DESKTOP_DOWNLOAD_URL } from "./types";
|
||||||
|
|
||||||
|
export const isMac = platform.window.platform === "darwin";
|
||||||
|
export const metaKey = isMac ? "⌘" : "Ctrl";
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { webPlatform } from "./web";
|
||||||
|
|
||||||
|
export const platform = webPlatform;
|
||||||
|
export type { Platform, ScreenSource } from "./types";
|
||||||
|
export { DESKTOP_DOWNLOAD_URL } from "./types";
|
||||||
|
|
||||||
|
export const isMac = platform.window.platform === "darwin";
|
||||||
|
export const metaKey = isMac ? "⌘" : "Ctrl";
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { AutoplayPayload } from "@/lib/autoplay-ipc";
|
||||||
|
import type { LinkMetadata } from "@/lib/link-metadata";
|
||||||
|
|
||||||
|
export interface ScreenSource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
thumbnailDataUrl: string;
|
||||||
|
appIconDataUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Platform {
|
||||||
|
kind: "electron" | "web";
|
||||||
|
|
||||||
|
window: {
|
||||||
|
minimize: () => void;
|
||||||
|
maximize: () => void;
|
||||||
|
fullscreen: () => void;
|
||||||
|
close: () => void;
|
||||||
|
onMaximizeChange: (cb: (m: boolean) => void) => () => void;
|
||||||
|
platform: "darwin" | "win32" | "linux" | "web";
|
||||||
|
};
|
||||||
|
|
||||||
|
huddle: {
|
||||||
|
isSupported: boolean;
|
||||||
|
open: (args: { token: string; serverUrl: string }) => void;
|
||||||
|
close: () => void;
|
||||||
|
getScreenSources: () => Promise<ScreenSource[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
screenRecord: {
|
||||||
|
isSupported: boolean;
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
cancel: () => void;
|
||||||
|
onStopRequested: (cb: () => void) => () => void;
|
||||||
|
getScreenSources: () => Promise<ScreenSource[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
autoplay: {
|
||||||
|
play: (payload: AutoplayPayload) => void;
|
||||||
|
dismiss: () => void;
|
||||||
|
navigate: (d: { networkId: string; streamId: string }) => void;
|
||||||
|
onPlay: (cb: (payload: AutoplayPayload) => void) => () => void;
|
||||||
|
onStop: (cb: () => void) => () => void;
|
||||||
|
onNavigate: (cb: (d: { networkId: string; streamId: string }) => void) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
link: {
|
||||||
|
fetchMetadata: (url: string) => Promise<LinkMetadata | null>;
|
||||||
|
openExternal: (url: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
attachment: {
|
||||||
|
download: (url: string, filename?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
app: {
|
||||||
|
setDockBadge: (count: number) => void;
|
||||||
|
getVersion: () => Promise<string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DESKTOP_DOWNLOAD_URL = "https://flowylabs.ai/llink/download";
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useAutoplayPayloadStore } from "@/stores/autoplay-payload-store";
|
||||||
|
import type { AutoplayPayload } from "@/lib/autoplay-ipc";
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import type { Platform } from "./types";
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
|
||||||
|
function detectWebPlatform(): "darwin" | "win32" | "linux" | "web" {
|
||||||
|
if (typeof navigator === "undefined") return "web";
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
if (/Mac|iPhone|iPad|iPod/i.test(ua)) return "darwin";
|
||||||
|
if (/Win/i.test(ua)) return "win32";
|
||||||
|
if (/Linux|X11/i.test(ua)) return "linux";
|
||||||
|
return "web";
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCrossOrigin(url: string, filename?: string) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
if (filename) a.download = filename;
|
||||||
|
a.target = "_blank";
|
||||||
|
a.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTitle = typeof document !== "undefined" ? document.title : "llink";
|
||||||
|
|
||||||
|
function applyDockBadge(count: number) {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.title = count > 0 ? `(${count}) ${baseTitle}` : baseTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOT_SUPPORTED = "Not supported in the web app";
|
||||||
|
|
||||||
|
export const webPlatform: Platform = {
|
||||||
|
kind: "web",
|
||||||
|
|
||||||
|
window: {
|
||||||
|
minimize: () => {},
|
||||||
|
maximize: () => {},
|
||||||
|
fullscreen: () => {},
|
||||||
|
close: () => {},
|
||||||
|
onMaximizeChange: () => () => {},
|
||||||
|
platform: detectWebPlatform(),
|
||||||
|
},
|
||||||
|
|
||||||
|
huddle: {
|
||||||
|
isSupported: false,
|
||||||
|
open: () => { throw new Error(NOT_SUPPORTED); },
|
||||||
|
close: () => {},
|
||||||
|
getScreenSources: async () => { throw new Error(NOT_SUPPORTED); },
|
||||||
|
},
|
||||||
|
|
||||||
|
screenRecord: {
|
||||||
|
isSupported: false,
|
||||||
|
start: () => { throw new Error(NOT_SUPPORTED); },
|
||||||
|
stop: () => {},
|
||||||
|
cancel: () => {},
|
||||||
|
onStopRequested: () => () => {},
|
||||||
|
getScreenSources: async () => { throw new Error(NOT_SUPPORTED); },
|
||||||
|
},
|
||||||
|
|
||||||
|
autoplay: {
|
||||||
|
play: (payload: AutoplayPayload) => {
|
||||||
|
useAutoplayPayloadStore.getState().setPayload(payload);
|
||||||
|
},
|
||||||
|
dismiss: () => {
|
||||||
|
useAutoplayPayloadStore.getState().setPayload(null);
|
||||||
|
},
|
||||||
|
navigate: (d) => {
|
||||||
|
useAutoplayPayloadStore.getState().setPayload(null);
|
||||||
|
useAutoplayPayloadStore.getState().setPendingNav(d);
|
||||||
|
},
|
||||||
|
onPlay: (cb) =>
|
||||||
|
useAutoplayPayloadStore.subscribe((state, prev) => {
|
||||||
|
if (state.payload && state.payload !== prev.payload) cb(state.payload);
|
||||||
|
}),
|
||||||
|
onStop: (cb) =>
|
||||||
|
useAutoplayPayloadStore.subscribe((state, prev) => {
|
||||||
|
if (!state.payload && prev.payload) cb();
|
||||||
|
}),
|
||||||
|
onNavigate: (cb) =>
|
||||||
|
useAutoplayPayloadStore.subscribe((state, prev) => {
|
||||||
|
if (state.pendingNav && state.pendingNav !== prev.pendingNav) {
|
||||||
|
cb({ networkId: state.pendingNav.networkId, streamId: state.pendingNav.streamId });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
link: {
|
||||||
|
fetchMetadata: (url) => apiClient.getLinkMetadata(url).catch(() => null),
|
||||||
|
openExternal: async (url) => {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
attachment: {
|
||||||
|
download: downloadCrossOrigin,
|
||||||
|
},
|
||||||
|
|
||||||
|
app: {
|
||||||
|
setDockBadge: applyDockBadge,
|
||||||
|
getVersion: async () => __APP_VERSION__,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { HashRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
export function RouterShell({ children }: PropsWithChildren) {
|
||||||
|
return <HashRouter>{children}</HashRouter>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
export function RouterShell({ children }: PropsWithChildren) {
|
||||||
|
return <BrowserRouter>{children}</BrowserRouter>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as Sentry from "@sentry/react";
|
||||||
|
import { appConfig, appEnv } from "@/config/env";
|
||||||
|
import { installErrorSinks } from "@/lib/errors";
|
||||||
|
|
||||||
|
export function initSentryRenderer(): void {
|
||||||
|
if (!appConfig.sentryDsn) return;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: appConfig.sentryDsn,
|
||||||
|
environment: appEnv,
|
||||||
|
tracesSampleRate: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
installErrorSinks({
|
||||||
|
capture: (err, context) =>
|
||||||
|
Sentry.captureException(err, { extra: context }),
|
||||||
|
breadcrumb: (err, context) =>
|
||||||
|
Sentry.addBreadcrumb({
|
||||||
|
category: "error",
|
||||||
|
level: "error",
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
data: context,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import path from 'node:path';
|
|||||||
import started from 'electron-squirrel-startup';
|
import started from 'electron-squirrel-startup';
|
||||||
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
||||||
|
|
||||||
import type { LinkMetadata } from './lib/link-metadata';
|
|
||||||
import { appConfig } from './config/env';
|
import { appConfig } from './config/env';
|
||||||
import { logError } from './lib/errors';
|
import { logError } from './lib/errors';
|
||||||
import { safeHandle } from './main/ipc-utils';
|
import { safeHandle } from './main/ipc-utils';
|
||||||
@@ -370,106 +369,6 @@ ipcMain.on('autoplay:navigate', (_event, data) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Link metadata ---
|
|
||||||
|
|
||||||
const metadataCache = new Map<string, LinkMetadata>();
|
|
||||||
|
|
||||||
function getMetaContent(html: string, property: string): string | null {
|
|
||||||
// Match both property="..." and name="..." attributes
|
|
||||||
const regex = new RegExp(
|
|
||||||
`<meta[^>]*(?:property|name)=["']${property}["'][^>]*content=["']([^"']*)["']|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${property}["']`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
const match = html.match(regex);
|
|
||||||
return match?.[1] ?? match?.[2] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTitle(html: string): string | null {
|
|
||||||
const match = html.match(/<title[^>]*>([^<]*)<\/title>/i);
|
|
||||||
return match?.[1]?.trim() ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFavicon(html: string, baseUrl: string): string | null {
|
|
||||||
const match = html.match(/<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']/i)
|
|
||||||
?? html.match(/<link[^>]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']/i);
|
|
||||||
if (!match?.[1]) {
|
|
||||||
// Fall back to /favicon.ico
|
|
||||||
try {
|
|
||||||
const url = new URL(baseUrl);
|
|
||||||
return `${url.protocol}//${url.host}/favicon.ico`;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return new URL(match[1], baseUrl).href;
|
|
||||||
} catch {
|
|
||||||
return match[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveUrl(src: string | null, baseUrl: string): string | null {
|
|
||||||
if (!src) return null;
|
|
||||||
try {
|
|
||||||
return new URL(src, baseUrl).href;
|
|
||||||
} catch {
|
|
||||||
return src;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLinkMetadata(url: string): Promise<LinkMetadata | null> {
|
|
||||||
const cached = metadataCache.get(url);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (compatible; llink/1.0)',
|
|
||||||
'Accept': 'text/html',
|
|
||||||
},
|
|
||||||
redirect: 'follow',
|
|
||||||
});
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (!response.ok) return null;
|
|
||||||
|
|
||||||
// Only read the first ~50KB to get <head> content
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
if (!reader) return null;
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
while (html.length < 50_000) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
html += decoder.decode(value, { stream: true });
|
|
||||||
}
|
|
||||||
reader.cancel();
|
|
||||||
|
|
||||||
const domain = new URL(url).hostname.replace(/^www\./, '');
|
|
||||||
const metadata: LinkMetadata = {
|
|
||||||
url,
|
|
||||||
title: getMetaContent(html, 'og:title') ?? getTitle(html),
|
|
||||||
description: getMetaContent(html, 'og:description') ?? getMetaContent(html, 'description'),
|
|
||||||
image: resolveUrl(getMetaContent(html, 'og:image'), url),
|
|
||||||
favicon: getFavicon(html, url),
|
|
||||||
domain,
|
|
||||||
};
|
|
||||||
|
|
||||||
metadataCache.set(url, metadata);
|
|
||||||
return metadata;
|
|
||||||
} catch (err) {
|
|
||||||
// Metadata is a progressive enhancement — keep the null contract, but log
|
|
||||||
// so upstream failures (DNS, TLS, aborted fetches) aren't invisible.
|
|
||||||
logError(err, { scope: 'link.fetchMetadata', url });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Dock badge ---
|
// --- Dock badge ---
|
||||||
|
|
||||||
ipcMain.on('app:set-dock-badge', (_event, count: number) => {
|
ipcMain.on('app:set-dock-badge', (_event, count: number) => {
|
||||||
@@ -480,16 +379,6 @@ ipcMain.on('app:set-dock-badge', (_event, count: number) => {
|
|||||||
|
|
||||||
safeHandle('app:get-version', () => app.getVersion());
|
safeHandle('app:get-version', () => app.getVersion());
|
||||||
|
|
||||||
safeHandle('link:fetch-metadata', async (_event, url) => {
|
|
||||||
if (typeof url !== 'string') return null;
|
|
||||||
try {
|
|
||||||
new URL(url);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return fetchLinkMetadata(url);
|
|
||||||
});
|
|
||||||
|
|
||||||
safeHandle('link:open-external', async (_event, url) => {
|
safeHandle('link:open-external', async (_event, url) => {
|
||||||
if (typeof url !== 'string') return;
|
if (typeof url !== 'string') return;
|
||||||
// Only allow http(s) URLs for security
|
// Only allow http(s) URLs for security
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ contextBridge.exposeInMainWorld('electronScreenRecord', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronLink', {
|
contextBridge.exposeInMainWorld('electronLink', {
|
||||||
fetchMetadata: (url: string) => ipcRenderer.invoke('link:fetch-metadata', url),
|
|
||||||
openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url),
|
openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { AutoplayPayload } from "@/lib/autoplay-ipc";
|
||||||
|
|
||||||
|
interface AutoplayPayloadState {
|
||||||
|
payload: AutoplayPayload | null;
|
||||||
|
pendingNav: { networkId: string; streamId: string; nonce: number } | null;
|
||||||
|
setPayload: (payload: AutoplayPayload | null) => void;
|
||||||
|
setPendingNav: (d: { networkId: string; streamId: string } | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAutoplayPayloadStore = create<AutoplayPayloadState>((set) => ({
|
||||||
|
payload: null,
|
||||||
|
pendingNav: null,
|
||||||
|
setPayload: (payload) => set({ payload }),
|
||||||
|
setPendingNav: (d) =>
|
||||||
|
set({ pendingNav: d ? { ...d, nonce: Date.now() } : null }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/png" href="/icon.png" />
|
||||||
|
<title>llink</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./renderer.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "@/App";
|
||||||
|
import { initSentryRenderer } from "@/lib/sentry";
|
||||||
|
import "@/styles/globals.css";
|
||||||
|
|
||||||
|
initSentryRenderer();
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<App />);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { createRequire } from "module";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineEnv } from "./vite.env";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const pkg = require("./package.json") as { version: string };
|
||||||
|
|
||||||
|
// Web build target for the browser. Aliases swap the platform adapter, the
|
||||||
|
// router shell, and Sentry over to web variants. Electron build is untouched
|
||||||
|
// — see vite.renderer.config.mts.
|
||||||
|
export default defineConfig({
|
||||||
|
root: path.resolve(__dirname, "src/web"),
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
// Exact-match regexes for the file-level swaps — `@/lib/platform` points at
|
||||||
|
// a file (`index.web.ts`), so a string-prefix alias would also swallow
|
||||||
|
// deeper paths like `@/lib/platform/desktop-only`. The generic `@` alias
|
||||||
|
// handles everything else.
|
||||||
|
alias: [
|
||||||
|
{ find: /^@\/lib\/platform$/, replacement: path.resolve(__dirname, "./src/lib/platform/index.web.ts") },
|
||||||
|
{ find: /^@\/lib\/sentry$/, replacement: path.resolve(__dirname, "./src/lib/sentry.web.ts") },
|
||||||
|
{ find: /^@\/lib\/router-shell$/, replacement: path.resolve(__dirname, "./src/lib/router-shell.web.tsx") },
|
||||||
|
{ find: "@", replacement: path.resolve(__dirname, "./src") },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
...defineEnv,
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: path.resolve(__dirname, "dist-web"),
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -734,6 +734,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
|
"@epic-web/invariant@^1.0.0":
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@epic-web/invariant/-/invariant-1.0.0.tgz#1073e5dee6dd540410784990eb73e4acd25c9813"
|
||||||
|
integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==
|
||||||
|
|
||||||
"@esbuild/[email protected]":
|
"@esbuild/[email protected]":
|
||||||
version "0.21.5"
|
version "0.21.5"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
|
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
|
||||||
@@ -2970,6 +2975,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@sentry/core" "10.47.0"
|
"@sentry/core" "10.47.0"
|
||||||
|
|
||||||
|
"@sentry-internal/[email protected]":
|
||||||
|
version "10.54.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.54.0.tgz#0aee09715307e271d9387cc1ddd43b9fed3e992a"
|
||||||
|
integrity sha512-Cz6NzYFmWJlHh1tvtltKsmLl+1jlseQaPXk18Z0P1g6lXAwhT3aJ99x7vDm4jwCzcJ12qAa8Oga8T3C23Ihijw==
|
||||||
|
dependencies:
|
||||||
|
"@sentry/core" "10.54.0"
|
||||||
|
|
||||||
"@sentry-internal/[email protected]":
|
"@sentry-internal/[email protected]":
|
||||||
version "10.47.0"
|
version "10.47.0"
|
||||||
resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.47.0.tgz#2a847b821f60802c4ed3d0da980ef57f593afb26"
|
resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.47.0.tgz#2a847b821f60802c4ed3d0da980ef57f593afb26"
|
||||||
@@ -2977,6 +2989,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@sentry/core" "10.47.0"
|
"@sentry/core" "10.47.0"
|
||||||
|
|
||||||
|
"@sentry-internal/[email protected]":
|
||||||
|
version "10.54.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.54.0.tgz#dddf105bbe5396748e0bd6fc2b2a954dd5e8fa6f"
|
||||||
|
integrity sha512-14D+TPgi75zogGQ/EWwtIm34FVWP34gso4SfJZRAoHiQrRfd907q8/7MTXNItxi81x79cH9vweu/o55LBml6MA==
|
||||||
|
dependencies:
|
||||||
|
"@sentry/core" "10.54.0"
|
||||||
|
|
||||||
"@sentry-internal/[email protected]":
|
"@sentry-internal/[email protected]":
|
||||||
version "10.47.0"
|
version "10.47.0"
|
||||||
resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.47.0.tgz#157256195f71592cd462fe5a57e71ddbd66e9cff"
|
resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.47.0.tgz#157256195f71592cd462fe5a57e71ddbd66e9cff"
|
||||||
@@ -2985,6 +3004,14 @@
|
|||||||
"@sentry-internal/replay" "10.47.0"
|
"@sentry-internal/replay" "10.47.0"
|
||||||
"@sentry/core" "10.47.0"
|
"@sentry/core" "10.47.0"
|
||||||
|
|
||||||
|
"@sentry-internal/[email protected]":
|
||||||
|
version "10.54.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.54.0.tgz#612f668b9de55102a223c693520b3572aa8fa39a"
|
||||||
|
integrity sha512-CGsH019npxnU5cocVDoZKod7JaQtaM6JiR6e2fI8tDwssohJAxP616UQTmoTtBLe3yLG18P4e1BxMxYZFalZEQ==
|
||||||
|
dependencies:
|
||||||
|
"@sentry-internal/replay" "10.54.0"
|
||||||
|
"@sentry/core" "10.54.0"
|
||||||
|
|
||||||
"@sentry-internal/[email protected]":
|
"@sentry-internal/[email protected]":
|
||||||
version "10.47.0"
|
version "10.47.0"
|
||||||
resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.47.0.tgz#33bb78457ee9731056d2b5a4805328e922b28886"
|
resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.47.0.tgz#33bb78457ee9731056d2b5a4805328e922b28886"
|
||||||
@@ -2993,6 +3020,14 @@
|
|||||||
"@sentry-internal/browser-utils" "10.47.0"
|
"@sentry-internal/browser-utils" "10.47.0"
|
||||||
"@sentry/core" "10.47.0"
|
"@sentry/core" "10.47.0"
|
||||||
|
|
||||||
|
"@sentry-internal/[email protected]":
|
||||||
|
version "10.54.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.54.0.tgz#5519d6d60f1d315d7b6e5ac1b649210ab6a182ff"
|
||||||
|
integrity sha512-B7eicNhAomJ7bGihJO7mCw7pZ8FFo/THQgGPo85VR3FaJVCCot20WxVgvhjc7IVBQVlaaxSrnlUFvA+yHjszqQ==
|
||||||
|
dependencies:
|
||||||
|
"@sentry-internal/browser-utils" "10.54.0"
|
||||||
|
"@sentry/core" "10.54.0"
|
||||||
|
|
||||||
"@sentry/[email protected]":
|
"@sentry/[email protected]":
|
||||||
version "10.47.0"
|
version "10.47.0"
|
||||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.47.0.tgz#286f5051ca82706c03e7a499b9464453225f3648"
|
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.47.0.tgz#286f5051ca82706c03e7a499b9464453225f3648"
|
||||||
@@ -3004,11 +3039,27 @@
|
|||||||
"@sentry-internal/replay-canvas" "10.47.0"
|
"@sentry-internal/replay-canvas" "10.47.0"
|
||||||
"@sentry/core" "10.47.0"
|
"@sentry/core" "10.47.0"
|
||||||
|
|
||||||
|
"@sentry/[email protected]":
|
||||||
|
version "10.54.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.54.0.tgz#9d8f32912cd3eb7ccf2825785f62bdeaabd745bf"
|
||||||
|
integrity sha512-XYuAA2E4Hf6NOJiP3PqczPgBhFUEsEAh+avgxcYTjTwYdr+Nh5XmDxXATr6RxXUvRASTiYN9zNWyK2o9kEDloA==
|
||||||
|
dependencies:
|
||||||
|
"@sentry-internal/browser-utils" "10.54.0"
|
||||||
|
"@sentry-internal/feedback" "10.54.0"
|
||||||
|
"@sentry-internal/replay" "10.54.0"
|
||||||
|
"@sentry-internal/replay-canvas" "10.54.0"
|
||||||
|
"@sentry/core" "10.54.0"
|
||||||
|
|
||||||
"@sentry/[email protected]":
|
"@sentry/[email protected]":
|
||||||
version "10.47.0"
|
version "10.47.0"
|
||||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.47.0.tgz#175d1865f0d762ebe7be3b2a6ec3ece4e5a76a5a"
|
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.47.0.tgz#175d1865f0d762ebe7be3b2a6ec3ece4e5a76a5a"
|
||||||
integrity sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA==
|
integrity sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA==
|
||||||
|
|
||||||
|
"@sentry/[email protected]":
|
||||||
|
version "10.54.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.54.0.tgz#b716c0cd7005ec94abf8e3a7d29fa87109038d93"
|
||||||
|
integrity sha512-yC/bc8N5ut6vk9X/ugTnIFAbzaSZ2uGoKiHRGzt7VseDIrjXk5ENDJP0m7Rbchuozr41kBv2QB3mPcHUhfB43w==
|
||||||
|
|
||||||
"@sentry/electron@^7.11.0":
|
"@sentry/electron@^7.11.0":
|
||||||
version "7.11.0"
|
version "7.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/@sentry/electron/-/electron-7.11.0.tgz#39a21578d3a92524748ed7b0574dde0aad65b2dc"
|
resolved "https://registry.yarnpkg.com/@sentry/electron/-/electron-7.11.0.tgz#39a21578d3a92524748ed7b0574dde0aad65b2dc"
|
||||||
@@ -3075,6 +3126,14 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@sentry/core" "10.47.0"
|
"@sentry/core" "10.47.0"
|
||||||
|
|
||||||
|
"@sentry/react@^10.54.0":
|
||||||
|
version "10.54.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.54.0.tgz#2f8dd953882aa9dcc2dfe623812269a83483ad9e"
|
||||||
|
integrity sha512-P9x2oJwm0LpJC3HUFfvFMcMZt3qW+PFznDk0hl+QI3BO/In07IvzpdQ/nWO81SHt0uwglwGs3bAjnN84YVzXIw==
|
||||||
|
dependencies:
|
||||||
|
"@sentry/browser" "10.54.0"
|
||||||
|
"@sentry/core" "10.54.0"
|
||||||
|
|
||||||
"@sindresorhus/is@^4.0.0":
|
"@sindresorhus/is@^4.0.0":
|
||||||
version "4.6.0"
|
version "4.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f"
|
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f"
|
||||||
@@ -4616,6 +4675,14 @@ cross-dirname@^0.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/cross-dirname/-/cross-dirname-0.1.0.tgz#b899599f30a5389f59e78c150e19f957ad16a37c"
|
resolved "https://registry.yarnpkg.com/cross-dirname/-/cross-dirname-0.1.0.tgz#b899599f30a5389f59e78c150e19f957ad16a37c"
|
||||||
integrity sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==
|
integrity sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==
|
||||||
|
|
||||||
|
cross-env@^10.1.0:
|
||||||
|
version "10.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-10.1.0.tgz#cfd2a6200df9ed75bfb9cb3d7ce609c13ea21783"
|
||||||
|
integrity sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==
|
||||||
|
dependencies:
|
||||||
|
"@epic-web/invariant" "^1.0.0"
|
||||||
|
cross-spawn "^7.0.6"
|
||||||
|
|
||||||
cross-spawn@^6.0.0:
|
cross-spawn@^6.0.0:
|
||||||
version "6.0.6"
|
version "6.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.6.tgz#30d0efa0712ddb7eb5a76e1e8721bffafa6b5d57"
|
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.6.tgz#30d0efa0712ddb7eb5a76e1e8721bffafa6b5d57"
|
||||||
|
|||||||
Reference in New Issue
Block a user