commit 3079d8e2708066c2f3f1ad79abe77c7c41c38879
Author: Sami Ahmed
Date: Sun Jun 7 01:25:14 2026 -0700
v1.6.8: public composer, beep wordmark on posts
diff --git a/README.md b/README.md
new file mode 100755
index 0000000..4a1861f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,114 @@
+# Beep
+
+A Twitter/X-style microblog for WordPress. Logged-in users post short updates with rich media, polls, GIFs, voice notes, and quotes. Like, reply (with nested threads), and share beeps. Designed to look like a Twitter timeline, hosted entirely on your own site.
+
+## What's in v1.5.1
+
+### Core Features
+- **Post beeps.** Short updates up to 280 characters, with a composer at the top of the feed.
+- **Like beeps and replies.** Hearts on every post and every reply, with a pop animation when you tap.
+- **Nested replies.** Reply to a reply, to a reply, to a reply. Visual indent caps at two levels (deeper threads still nest structurally).
+- **Latest / Top tabs.** "Top" sorts your feed by like count — a built-in way for readers to surface the best stuff.
+- **Per-post Beep badge.** Every beep and reply carries a small bird icon in the corner, branding it as a Beep.
+- **Friendlier visitor experience.** Logged-out visitors see clean read-only beeps; logged-in users get the full interactive feed.
+
+### Rich Media
+- **Image uploads.** Attach up to 4 images per beep. Previews in the composer with remove buttons. Images display in an adaptive grid (1×, 2×, 3×, 4× layouts).
+- **GIF picker (Giphy).** Search and embed GIFs via the Giphy API. Configure your API key in **Settings → Beep**.
+- **Voice notes.** Record and attach audio notes (MP3, OGG, WAV, WebM, M4A) up to 20 MB. Inline audio player with play button and waveform visualization.
+- **Quote beeps.** Reference any existing beep as a quote. Loads the quoted post's author, time, and content inline.
+
+### Polls
+- **Create polls.** Add a poll to any beep with a question and 2–6 options.
+- **Vote on polls.** One-click voting with live results displayed as progress bars.
+- **Poll management.** Add/remove options before posting; polls are cleared after submission.
+
+### Thread View
+- **Conversation modal.** Click any beep to open a full thread/conversation view in a modal overlay.
+- **Nested thread display.** Replies are indented by depth for easy reading.
+
+### Settings & Integration
+- **Admin settings page.** Configure character limit, Giphy API key, and other options under **Settings → Beep**.
+- **Elementor widget.** Drag-and-drop Beep feed into any Elementor-built page.
+- **Magic login support.** Works with passwordless login plugins for frictionless onboarding.
+
+### Moderation
+- **Delete any beep or reply.** Logged-in admins see a small × in the top-right of each item.
+- **Approve replies before they appear.** Settings → Discussion → "Comment must be manually approved" puts replies in a queue at **Comments** in the sidebar.
+- **Suspend users.** Users → click their name → scroll to **Chirp moderation** → check **Suspend from beeping**.
+
+## Install Beep
+
+1. In wp-admin, go to **Plugins → Add New → Upload Plugin**.
+2. Upload `beep.zip` and click **Install Now**, then **Activate**.
+3. On the page you want the feed (your one-page site, a dedicated "Feed" page, etc.), add the shortcode:
+
+ ```
+ [beep_feed]
+ ```
+
+ Save and visit the page. As an admin, you'll see a composer at the top — type a message and click "Beep" to post.
+
+## Invite friends (with magic links — no passwords)
+
+This is the easiest way to let a small circle into your Beep feed without setting up Google/Facebook developer apps. The flow:
+
+1. **Open registration.** Go to **Settings → General**, check **Anyone can register**, and set **New User Default Role** to **Subscriber**. Save.
+2. **Install passwordless login.** Plugins → Add New → search for **Passwordless Login** (any well-reviewed one — Cozmoslabs or Magic Login both work). Install and activate.
+3. **Share the sign-up link.** Send your friends a direct link to:
+
+ ```
+ https://your-site.com/wp-login.php?action=register
+ ```
+
+ They type their email, get a one-time sign-in link, and they're in. No password to remember.
+4. **They can now like and reply.** Liking and replying require being logged in; your feed remains read-only for the public.
+
+## Shortcode options
+
+```
+[beep_feed limit="50" header="Messages from" sort="latest"]
+```
+
+- `limit` — how many beeps to load on first render (default 50)
+- `header` — text shown next to the wordmark (default "Messages from")
+- `sort` — initial sort: `latest` (default) or `top`
+
+## Elementor Widget
+
+After activating Beep, you'll find a **Beep Feed** widget in the Elementor panel. Drag it onto your page — it renders the same feed as the shortcode, with no extra configuration needed.
+
+## Branding
+
+Swap the bird/wordmark in `assets/`. The accent color is `#1D9BF0`; find/replace in `assets/beep.css` for a different palette.
+
+## Changelog
+
+### v1.5.1
+- Fixed critical IIFE scope bug — all functions now properly access `config` and `handleResponse`
+- Removed leaked Giphy API key from JavaScript source
+- Fixed voice note upload not clearing composer state
+- Added CSS for GIF picker modal, poll compose section, media preview grid, voice note preview
+- Added loading spinner and empty states for feed
+- Added responsive styles for GIF grid and media preview on mobile
+
+### v1.5
+- Image uploads with 4-image grid
+- GIF picker via Giphy API
+- Voice note attachments
+- Quote beeps
+- Polls with voting
+- Thread/conversation modal
+- Elementor widget
+- Admin settings page
+
+### v1.4
+- Like beeps and replies with pop animation
+- Nested replies (multiple levels deep)
+- Latest / Top tabs
+- Per-post Beep badge
+- Read-only mode for logged-out visitors
+
+## License
+
+GPLv2 or later, same as WordPress.
diff --git a/assets/beep-bird-badge.png b/assets/beep-bird-badge.png
new file mode 100755
index 0000000..00c286e
Binary files /dev/null and b/assets/beep-bird-badge.png differ
diff --git a/assets/beep-bird.png b/assets/beep-bird.png
new file mode 100755
index 0000000..c5a27d6
Binary files /dev/null and b/assets/beep-bird.png differ
diff --git a/assets/beep-icon-small.png b/assets/beep-icon-small.png
new file mode 100755
index 0000000..56a4c45
Binary files /dev/null and b/assets/beep-icon-small.png differ
diff --git a/assets/beep-icon.png b/assets/beep-icon.png
new file mode 100755
index 0000000..269c34b
Binary files /dev/null and b/assets/beep-icon.png differ
diff --git a/assets/beep-icon.svg b/assets/beep-icon.svg
new file mode 100755
index 0000000..28b2eb2
--- /dev/null
+++ b/assets/beep-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/beep-logo.png b/assets/beep-logo.png
new file mode 100755
index 0000000..2418e8d
Binary files /dev/null and b/assets/beep-logo.png differ
diff --git a/assets/beep-logo.svg b/assets/beep-logo.svg
new file mode 100755
index 0000000..6f3a098
--- /dev/null
+++ b/assets/beep-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/beep-wordmark-blue.png b/assets/beep-wordmark-blue.png
new file mode 100644
index 0000000..1eb721d
Binary files /dev/null and b/assets/beep-wordmark-blue.png differ
diff --git a/assets/beep-wordmark.png b/assets/beep-wordmark.png
new file mode 100755
index 0000000..018edcd
Binary files /dev/null and b/assets/beep-wordmark.png differ
diff --git a/assets/beep.css b/assets/beep.css
new file mode 100755
index 0000000..0e03e94
--- /dev/null
+++ b/assets/beep.css
@@ -0,0 +1,1077 @@
+/* ============================================================
+ Beep — Twitter/X Dark Mode
+ Post and feed styling to match Twitter/X perfectly
+ ============================================================ */
+
+.beep-embed .beep-feed,
+.beep-embed .beep-feed * {
+ box-sizing: border-box;
+}
+
+:root {
+ /* Default Twitter Dark Mode Palette */
+ --beep-bg: #15202B;
+ --beep-card-bg: #192734;
+ --beep-card-hover: #1C2732;
+ --beep-text: #E7E9EA;
+ --beep-muted: #71767B;
+ --beep-border: #2F3336;
+ --beep-accent: #1D9BF0;
+ --beep-accent-hover: #1A8CD8;
+ --beep-like: #F91880;
+ --beep-repost: #00BA7C;
+}
+
+
+.beep-embed .beep-feed {
+ /* Twitter Dark Mode Palette */
+
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ max-width: 600px;
+ margin: 0 auto;
+ background: var(--beep-bg);
+ border-left: 1px solid var(--beep-border);
+ border-right: 1px solid var(--beep-border);
+ color: var(--beep-text);
+ font-size: 15px;
+ line-height: 1.4;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ min-height: 0;
+}
+
+/* Header */
+.beep-embed .beep-feed-header {
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--beep-border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ background: rgba(21, 32, 43, 0.85);
+ backdrop-filter: blur(12px);
+}
+
+.beep-embed .beep-feed-header-title {
+ font-size: 20px;
+ font-weight: 800;
+ color: var(--beep-text);
+ letter-spacing: -0.5px;
+}
+
+/* Nav tabs */
+.beep-embed .beep-top-nav {
+ display: flex;
+ border-bottom: 1px solid var(--beep-border);
+}
+
+.beep-embed .beep-nav-tab {
+ flex: 1;
+ background: transparent;
+ border: 0;
+ padding: 16px 0;
+ font-family: inherit;
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--beep-muted);
+ cursor: pointer;
+ position: relative;
+ text-align: center;
+ transition: background-color 0.15s ease, color 0.15s ease;
+}
+
+.beep-embed .beep-nav-tab:hover {
+ background: var(--beep-card-hover);
+ color: var(--beep-text);
+}
+
+.beep-embed .beep-nav-tab.is-active {
+ color: var(--beep-text);
+}
+
+.beep-embed .beep-nav-tab.is-active::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 4px;
+ background: var(--beep-accent);
+ border-radius: 9999px;
+}
+
+/* Compose area */
+.beep-embed .beep-compose {
+ display: flex;
+ gap: 12px;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--beep-border);
+ background: var(--beep-bg);
+}
+
+.beep-embed .beep-compose-body {
+ flex: 1;
+ min-width: 0;
+}
+
+.beep-embed .beep-feed .beep-input {
+ width: 100% !important;
+ border: 0 !important;
+ outline: 0 !important;
+ resize: none !important;
+ background: transparent !important;
+ font-family: inherit !important;
+ font-size: 18px !important;
+ line-height: 1.5 !important;
+ color: var(--beep-text) !important;
+ padding: 8px 0 !important;
+ min-height: 52px;
+ overflow: hidden;
+ box-shadow: none !important;
+ caret-color: var(--beep-accent);
+}
+
+.beep-embed .beep-feed .beep-input::placeholder {
+ color: var(--beep-muted) !important;
+}
+
+.beep-embed .beep-compose-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--beep-border);
+}
+
+.beep-embed .beep-compose-tools {
+ display: flex;
+ gap: 4px;
+}
+
+.beep-embed .beep-compose-tool {
+ background: transparent;
+ border: 0;
+ color: var(--beep-accent);
+ cursor: pointer;
+ width: 36px;
+ height: 36px;
+ border-radius: 9999px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.15s ease;
+}
+
+.beep-embed .beep-compose-tool:hover {
+ background: rgba(29, 155, 240, 0.15);
+}
+
+/* Buttons */
+.beep-embed .beep-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--beep-accent);
+ color: #fff;
+ border: 0;
+ border-radius: 9999px;
+ padding: 8px 20px;
+ font-weight: 700;
+ font-size: 15px;
+ cursor: pointer;
+ font-family: inherit;
+ transition: background-color 0.15s ease, transform 0.1s ease;
+}
+
+.beep-embed .beep-button:hover { background: var(--beep-accent-hover); color: #fff; }
+.beep-embed .beep-button:active { transform: scale(0.96); }
+.beep-embed .beep-button:disabled { opacity: 0.5; cursor: default; }
+
+/* Follow button */
+.beep-embed .beep-follow-btn {
+ background: var(--beep-text);
+ color: var(--beep-bg);
+ border: 0;
+ border-radius: 9999px;
+ padding: 6px 16px;
+ font-weight: 700;
+ font-size: 14px;
+ cursor: pointer;
+ font-family: inherit;
+ transition: opacity 0.15s ease;
+ white-space: nowrap;
+}
+
+.beep-embed .beep-follow-btn:hover { opacity: 0.85; }
+
+.beep-embed .beep-following-btn {
+ background: transparent;
+ color: var(--beep-text);
+ border: 1px solid var(--beep-border);
+ border-radius: 9999px;
+ padding: 6px 16px;
+ font-weight: 700;
+ font-size: 14px;
+ cursor: pointer;
+ font-family: inherit;
+ transition: border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease;
+}
+
+.beep-embed .beep-following-btn:hover {
+ border-color: #F4212E;
+ color: #F4212E;
+ background: rgba(244, 33, 46, 0.1);
+}
+
+/* Char counter */
+.beep-embed .beep-char-counter { font-size: 13px; color: var(--beep-muted); font-variant-numeric: tabular-nums; }
+.beep-embed .beep-counter-warn { color: #FFD400; }
+.beep-embed .beep-counter-bad { color: #F4212E; font-weight: 700; }
+
+/* Avatars */
+.beep-embed .beep-feed .beep-avatar {
+ width: 56px !important;
+ height: 56px !important;
+ min-width: 56px !important;
+ max-width: 56px !important;
+ border-radius: 50% !important;
+ flex-shrink: 0;
+ background: var(--beep-border);
+ object-fit: cover !important;
+ aspect-ratio: 1 / 1 !important;
+ display: block !important;
+ border: 0 !important;
+ transition: filter 0.15s ease;
+}
+
+.beep-embed .beep-feed .beep-avatar:hover { filter: brightness(0.9); }
+
+.beep-embed .beep-feed .beep-avatar-sm {
+ width: 40px !important;
+ height: 40px !important;
+ min-width: 40px !important;
+ max-width: 40px !important;
+}
+
+/* Post list */
+.beep-embed .beep-list { background: var(--beep-bg); }
+
+.beep-embed .beep-empty {
+ padding: 60px 20px;
+ text-align: center;
+ color: var(--beep-muted);
+}
+
+/* Individual post */
+.beep-embed .beep-post {
+ display: flex;
+ gap: 12px;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--beep-border);
+ background: var(--beep-bg);
+ transition: background-color 0.15s ease;
+ cursor: pointer;
+}
+
+.beep-embed .beep-post:hover { background: var(--beep-card-hover); }
+.beep-embed .beep-post:last-child { border-bottom: 0; }
+
+.beep-embed .beep-post-body { flex: 1; min-width: 0; }
+
+/* Meta line */
+.beep-embed .beep-meta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 16px;
+ line-height: 1.3;
+ margin-bottom: 4px;
+ flex-wrap: wrap;
+}
+
+.beep-embed .beep-name {
+ font-weight: 700;
+ color: var(--beep-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.beep-embed .beep-name:hover { text-decoration: underline; cursor: pointer; }
+
+.beep-embed .beep-handle,
+.beep-embed .beep-dot,
+.beep-embed .beep-time { color: var(--beep-muted); font-weight: 400; white-space: nowrap; }
+
+.beep-embed .beep-time { text-decoration: none; }
+.beep-embed .beep-time:hover { text-decoration: underline; cursor: pointer; }
+
+.beep-embed .beep-spacer { flex: 1; }
+
+/* Verified badge */
+.beep-embed .beep-verified {
+ width: 18px;
+ height: 18px;
+ fill: var(--beep-accent);
+ flex-shrink: 0;
+ margin-left: 2px;
+ vertical-align: text-bottom;
+}
+
+/* Post text */
+.beep-embed .beep-text {
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--beep-text);
+ word-wrap: break-word;
+ overflow-wrap: anywhere;
+ margin: 0;
+ white-space: pre-wrap;
+}
+
+.beep-embed .beep-tag, .beep-embed .beep-mention { color: var(--beep-text); text-decoration: none; cursor: pointer; }
+.beep-embed .beep-tag:hover, .beep-embed .beep-mention:hover { text-decoration: underline; }
+
+.beep-embed .beep-link { color: var(--beep-accent); text-decoration: none; }
+.beep-embed .beep-link:hover { text-decoration: underline; }
+
+/* Action bar */
+.beep-embed .beep-actions {
+ display: flex;
+ justify-content: space-between;
+ max-width: 425px;
+ margin-top: 12px;
+}
+
+.beep-embed .beep-action {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: transparent;
+ border: 0;
+ color: var(--beep-muted);
+ font-size: 14px;
+ font-family: inherit;
+ cursor: pointer;
+ padding: 8px 10px;
+ margin: -8px -10px;
+ border-radius: 9999px;
+ transition: color 0.15s ease, background-color 0.15s ease;
+}
+
+.beep-embed .beep-action svg,
+.beep-embed .beep-action .beep-icon-svg {
+ width: 22px;
+ height: 22px;
+ fill: currentColor;
+ display: block;
+ flex-shrink: 0;
+}
+
+.beep-embed .beep-count {
+ font-variant-numeric: tabular-nums;
+ min-width: 20px;
+}
+
+/* Reply - blue */
+.beep-embed .beep-action-reply:hover {
+ color: var(--beep-accent);
+ background: rgba(29, 155, 240, 0.1);
+}
+
+/* Retweet - green */
+.beep-embed .beep-action-retweet:hover {
+ color: var(--beep-repost);
+ background: rgba(0, 186, 124, 0.1);
+}
+
+.beep-embed .beep-action.is-reposted { color: var(--beep-repost); }
+
+/* Like - pink */
+.beep-embed .beep-action-like:hover {
+ color: var(--beep-like);
+ background: rgba(249, 24, 128, 0.1);
+}
+
+.beep-embed .beep-action.is-liked { color: var(--beep-like); }
+
+/* Bookmark - blue */
+.beep-embed .beep-action-bookmark:hover {
+ color: var(--beep-accent);
+ background: rgba(29, 155, 240, 0.1);
+}
+
+.beep-embed .beep-action.is-bookmarked { color: var(--beep-accent); }
+
+/* Share - blue */
+.beep-embed .beep-action-share:hover {
+ color: var(--beep-accent);
+ background: rgba(29, 155, 240, 0.1);
+}
+
+/* Heart pop animation */
+.beep-embed .beep-action-like.beep-just-liked .beep-icon-filled {
+ animation: beep-heart-pop 0.55s ease;
+}
+
+@keyframes beep-heart-pop {
+ 0% { transform: scale(0.85); }
+ 40% { transform: scale(1.35); }
+ 70% { transform: scale(0.9); }
+ 100% { transform: scale(1); }
+}
+
+/* Retweet spin animation */
+.beep-embed .beep-action-retweet.beep-just-retweeted svg {
+ animation: beep-retweet-spin 0.4s ease;
+}
+
+@keyframes beep-retweet-spin {
+ 0% { transform: rotate(0deg) scale(1); }
+ 50% { transform: rotate(180deg) scale(1.2); }
+ 100% { transform: rotate(360deg) scale(1); }
+}
+
+/* Media */
+.beep-embed .beep-media-attachments {
+ margin-top: 12px;
+ border-radius: 16px;
+ overflow: hidden;
+ border: 1px solid var(--beep-border);
+}
+
+.beep-embed .beep-media-attachments img {
+ width: 100%;
+ height: auto;
+ display: block;
+ max-height: 400px;
+ object-fit: cover;
+}
+
+/* Image grid */
+.beep-embed .beep-media-grid {
+ display: grid;
+ gap: 2px;
+ border-radius: 16px;
+ overflow: hidden;
+ border: 1px solid var(--beep-border);
+}
+
+.beep-embed .beep-media-grid-2 { grid-template-columns: 1fr 1fr; }
+.beep-embed .beep-media-grid-3 .beep-media-grid-item:first-child { grid-column: span 2; }
+.beep-embed .beep-media-grid-4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
+
+.beep-embed .beep-media-grid-item {
+ overflow: hidden;
+ aspect-ratio: 16 / 9;
+}
+
+.beep-embed .beep-media-grid-item img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ max-height: none;
+}
+
+/* Polls */
+.beep-embed .beep-poll-container {
+ margin-top: 12px;
+ border: 1px solid var(--beep-border);
+ border-radius: 16px;
+ padding: 16px;
+ background: transparent;
+}
+
+.beep-embed .beep-poll-question {
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--beep-text);
+ margin-bottom: 12px;
+}
+
+.beep-embed .beep-poll-options {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.beep-embed .beep-poll-option {
+ border: 1px solid var(--beep-border);
+ border-radius: 9999px;
+ padding: 10px 16px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--beep-text);
+ background: transparent;
+ transition: background-color 0.15s ease, border-color 0.15s ease;
+ position: relative;
+ overflow: hidden;
+}
+
+.beep-embed .beep-poll-option:hover {
+ background: var(--beep-card-hover);
+ border-color: var(--beep-muted);
+}
+
+.beep-embed .beep-poll-option.is-voted { border-color: var(--beep-accent); }
+
+.beep-embed .beep-poll-result {
+ border: 1px solid var(--beep-border);
+ border-radius: 9999px;
+ padding: 10px 16px;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--beep-text);
+ position: relative;
+ overflow: hidden;
+}
+
+.beep-embed .beep-poll-result-bar {
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ background: var(--beep-accent);
+ opacity: 0.3;
+ max-width: 100%;
+}
+
+.beep-embed .beep-poll-result.is-voted { border-color: var(--beep-accent); }
+.beep-embed .beep-poll-result-text { position: relative; z-index: 1; }
+.beep-embed .beep-poll-end { font-size: 13px; color: var(--beep-muted); margin-top: 8px; font-weight: 400; }
+
+/* Quotes */
+.beep-embed .beep-quoted-container {
+ margin-top: 12px;
+ border: 1px solid var(--beep-border);
+ border-radius: 16px;
+ padding: 12px 16px;
+ background: transparent;
+}
+
+.beep-embed .beep-quoted-post { text-decoration: none; color: inherit; }
+.beep-embed .beep-quoted-post:hover { text-decoration: none; }
+
+.beep-embed .beep-quoted-meta { font-size: 13px; color: var(--beep-muted); margin-bottom: 4px; }
+.beep-embed .beep-quoted-meta .beep-name { font-weight: 700; }
+
+.beep-embed .beep-quoted-text {
+ font-size: 14px;
+ color: var(--beep-text);
+ line-height: 1.4;
+ margin: 0;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+/* Voice note */
+.beep-embed .beep-voice-note {
+ margin-top: 12px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: var(--beep-card-hover);
+ border-radius: 9999px;
+ padding: 10px 16px;
+ max-width: 400px;
+}
+
+.beep-embed .beep-voice-play {
+ background: var(--beep-accent);
+ border: 0;
+ border-radius: 50%;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex-shrink: 0;
+ padding: 0;
+}
+
+.beep-embed .beep-voice-play svg { width: 14px; height: 14px; fill: #fff; }
+
+.beep-embed .beep-voice-waveform {
+ flex: 1;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.beep-embed .beep-voice-bar {
+ width: 3px;
+ height: 100%;
+ background: var(--beep-accent);
+ border-radius: 9999px;
+ opacity: 0.6;
+}
+
+.beep-embed .beep-voice-duration { font-size: 13px; color: var(--beep-muted); font-variant-numeric: tabular-nums; }
+
+/* Replies */
+.beep-embed .beep-replies {
+ margin-top: 8px;
+ padding-left: 0;
+ border-left: 2px solid var(--beep-border);
+ margin-left: 24px;
+}
+
+.beep-embed .beep-reply {
+ display: flex;
+ gap: 10px;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--beep-border);
+ background: var(--beep-bg);
+ transition: background-color 0.15s ease;
+}
+
+.beep-embed .beep-reply:last-child { border-bottom: 0; }
+.beep-embed .beep-reply-body { flex: 1; min-width: 0; }
+.beep-embed .beep-reply .beep-text { font-size: 14px; margin-bottom: 0; }
+.beep-embed .beep-reply .beep-meta { font-size: 13px; margin-bottom: 0; }
+.beep-embed .beep-reply .beep-avatar { width: 36px !important; height: 36px !important; min-width: 36px !important; max-width: 36px !important; }
+
+/* Reply form */
+.beep-embed .beep-reply-form {
+ margin-top: 8px;
+ padding: 12px 16px;
+ border-top: 1px solid var(--beep-border);
+ border-bottom: 1px solid var(--beep-border);
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ background: var(--beep-bg);
+}
+
+.beep-embed .beep-reply-form[hidden] { display: none; }
+.beep-embed .beep-reply-form-body { flex: 1; min-width: 0; }
+
+.beep-embed .beep-feed .beep-reply-input {
+ width: 100% !important;
+ border: 0 !important;
+ outline: 0 !important;
+ resize: none !important;
+ background: transparent !important;
+ font-family: inherit !important;
+ font-size: 15px !important;
+ color: var(--beep-text) !important;
+ padding: 6px 0 !important;
+ min-height: 36px;
+ box-shadow: none !important;
+ caret-color: var(--beep-accent);
+}
+
+.beep-embed .beep-feed .beep-reply-input::placeholder { color: var(--beep-muted) !important; }
+
+.beep-embed .beep-reply-footer {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ margin-top: 8px;
+}
+
+/* Animations */
+.beep-embed .beep-new { animation: beep-slide-in 0.3s ease; }
+
+@keyframes beep-slide-in {
+ from { opacity: 0; transform: translateY(-8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* Post badge */
+.beep-embed .beep-post-badge {
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
+ opacity: 0.7;
+ margin-left: 4px;
+}
+
+.beep-embed .beep-post-badge:hover { opacity: 1; cursor: pointer; }
+
+/* Loading */
+.beep-embed .beep-loading { opacity: 0.5; pointer-events: none; }
+
+/* Mobile */
+@media (max-width: 640px) {
+ .beep-embed .beep-feed { margin: 0; border-left: 0; border-right: 0; }
+ .beep-embed .beep-post { padding: 12px 16px; }
+ .beep-embed .beep-actions { max-width: none; }
+ .beep-embed .beep-compose { padding: 12px 16px; }
+ .beep-embed .beep-feed-header { padding: 12px 16px; }
+}
+
+/* Delete */
+/* .beep-delete-row replaced by .beep-bottom-bar */
+
+.beep-embed .beep-delete-link {
+ background: transparent;
+ border: 0;
+ color: var(--beep-muted);
+ font-family: inherit;
+ font-size: 13px;
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 4px;
+ transition: color 0.15s ease, background-color 0.15s ease;
+}
+
+.beep-embed .beep-delete-link:hover { color: #F4212E; background: rgba(244, 33, 46, 0.1); }
+
+/* Notice */
+.beep-embed .beep-notice { padding: 14px 20px; border-bottom: 1px solid var(--beep-border); font-size: 14px; }
+.beep-embed .beep-notice p { margin: 0; color: var(--beep-muted); }
+.beep-embed .beep-notice-banned { color: #F4212E; font-style: italic; }
+
+/* Header post button */
+.beep-embed .beep-post-btn-header {
+ background: var(--beep-accent);
+ color: #fff;
+ border: 0;
+ border-radius: 9999px;
+ padding: 8px 20px;
+ font-weight: 700;
+ font-size: 15px;
+ cursor: pointer;
+ font-family: inherit;
+ transition: background-color 0.15s ease, transform 0.1s ease;
+}
+
+.beep-embed .beep-post-btn-header:hover { background: var(--beep-accent-hover); }
+.beep-embed .beep-post-btn-header:active { transform: scale(0.96); }
+
+/* Back link */
+.beep-embed .beep-back-link {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 20px;
+ border-bottom: 1px solid var(--beep-border);
+ background: var(--beep-bg);
+ color: var(--beep-text);
+ text-decoration: none;
+ font-weight: 700;
+ font-size: 19px;
+ transition: background-color 0.15s ease;
+}
+
+.beep-embed .beep-back-link:hover { background: var(--beep-card-hover); color: var(--beep-text); text-decoration: none; }
+.beep-embed .beep-back-link svg { width: 20px; height: 20px; fill: currentColor; }
+
+/* Thread modal */
+.beep-embed .beep-thread-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.4);
+ backdrop-filter: blur(4px);
+}
+
+.beep-embed .beep-thread-modal[hidden] { display: none; }
+
+.beep-embed .beep-thread-content {
+ background: var(--beep-card-bg);
+ width: 100%;
+ max-width: 600px;
+ max-height: 90vh;
+ overflow-y: auto;
+ border-left: 1px solid var(--beep-border);
+ border-right: 1px solid var(--beep-border);
+}
+
+.beep-embed .beep-thread-header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 12px 20px;
+ border-bottom: 1px solid var(--beep-border);
+ position: sticky;
+ top: 0;
+ background: var(--beep-card-bg);
+ z-index: 1;
+}
+
+.beep-embed .beep-thread-close {
+ background: transparent;
+ border: 0;
+ color: var(--beep-text);
+ cursor: pointer;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ transition: background-color 0.15s ease;
+}
+
+.beep-embed .beep-thread-close:hover { background: var(--beep-card-hover); }
+.beep-embed .beep-thread-close svg { width: 20px; height: 20px; fill: currentColor; }
+.beep-embed .beep-thread-title { font-size: 18px; font-weight: 700; color: var(--beep-text); }
+
+/* Tooltip */
+.beep-embed .beep-tip {
+ position: absolute;
+ margin-left: 8px;
+ padding: 4px 10px;
+ background: var(--beep-text);
+ color: var(--beep-bg);
+ border-radius: 4px;
+ font-size: 12px;
+ white-space: nowrap;
+ pointer-events: none;
+ z-index: 100;
+ animation: beep-tip-fade 1.5s ease forwards;
+}
+
+@keyframes beep-tip-fade {
+ 0% { opacity: 0; transform: translateY(-4px); }
+ 20% { opacity: 1; transform: translateY(0); }
+ 80% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+/* GIF */
+.beep-embed .beep-gif-embed {
+ margin-top: 12px;
+ border-radius: 12px;
+ overflow: hidden;
+ border: 1px solid var(--beep-border);
+}
+
+.beep-embed .beep-gif-embed img {
+ width: 100%;
+ height: auto;
+ max-height: 400px;
+ object-fit: cover;
+ display: block;
+ margin: 0;
+ border-radius: 12px;
+}
+
+/* Profile header */
+.beep-embed .beep-profile-header { padding: 16px 20px; border-bottom: 1px solid var(--beep-border); }
+.beep-embed .beep-profile-name { font-size: 20px; font-weight: 800; color: var(--beep-text); margin-bottom: 2px; }
+.beep-embed .beep-profile-meta { font-size: 14px; color: var(--beep-muted); }
+.beep-embed .beep-profile-stats { display: flex; gap: 20px; margin-top: 12px; }
+.beep-embed .beep-profile-stat { color: var(--beep-muted); font-size: 14px; cursor: pointer; }
+.beep-embed .beep-profile-stat:hover { text-decoration: underline; }
+.beep-embed .beep-profile-stat strong { color: var(--beep-text); font-weight: 700; }
+
+/* Tabs */
+.beep-embed .beep-tabs {
+ display: flex;
+ border-bottom: 1px solid var(--beep-border);
+ background: var(--beep-card-bg);
+}
+
+.beep-embed .beep-tab {
+ flex: 1;
+ background: transparent;
+ border: 0;
+ padding: 14px 0;
+ font-family: inherit;
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--beep-muted);
+ cursor: pointer;
+ position: relative;
+ transition: background-color 0.15s ease, color 0.15s ease;
+}
+
+.beep-embed .beep-tab:hover { background: var(--beep-card-hover); color: var(--beep-text); }
+.beep-embed .beep-tab.is-active { color: var(--beep-text); }
+.beep-embed .beep-tab.is-active::after {
+ content: '';
+ position: absolute;
+ left: 50%;
+ bottom: 0;
+ transform: translateX(-50%);
+ width: 56px;
+ height: 4px;
+ border-radius: 9999px;
+ background: var(--beep-accent);
+}
+
+/* Focus */
+.beep-embed .beep-feed .beep-input:focus,
+.beep-embed .beep-feed .beep-input:focus-visible,
+.beep-embed .beep-feed .beep-reply-input:focus,
+.beep-embed .beep-feed .beep-reply-input:focus-visible {
+ outline: 0 !important;
+ box-shadow: none !important;
+}
+
+.beep-embed .beep-feed .beep-compose:focus-within { background: rgba(29, 155, 240, 0.03); }
+
+/* Hidden */
+.beep-embed [hidden] { display: none !important; }
+
+
+/* Magic Login Form */
+.beep-embed .beep-magic-login {
+ padding: 24px;
+ display: flex;
+ justify-content: center;
+}
+
+.beep-embed .beep-magic-login-card {
+ background: #192734;
+ border-radius: 16px;
+ padding: 32px;
+ width: 100%;
+ max-width: 400px;
+ text-align: center;
+}
+
+.beep-embed .beep-magic-login-header {
+ margin-bottom: 24px;
+}
+
+.beep-embed .beep-magic-login-header svg {
+ margin-bottom: 16px;
+}
+
+.beep-embed .beep-magic-login-header h3 {
+ color: #E7E9EA;
+ font-size: 20px;
+ font-weight: 700;
+ margin: 0 0 8px 0;
+}
+
+.beep-embed .beep-magic-login-header p {
+ color: #71767B;
+ font-size: 14px;
+ margin: 0;
+}
+
+.beep-embed .beep-magic-login-form {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.beep-embed .beep-magic-email {
+ width: 100%;
+ padding: 12px 16px;
+ background: #22303C;
+ border: 1px solid #333F4D;
+ border-radius: 8px;
+ color: #E7E9EA;
+ font-size: 16px;
+ box-sizing: border-box;
+}
+
+.beep-embed .beep-magic-email:focus {
+ outline: none;
+ border-color: #1D9BF0;
+}
+
+.beep-embed .beep-magic-email::placeholder {
+ color: #71767B;
+}
+
+.beep-embed .beep-button.beep-magic-submit {
+ width: 100%;
+ padding: 12px 24px;
+ background: #1D9BF0;
+ color: #fff;
+ border: none;
+ border-radius: 8px;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.beep-embed .beep-button.beep-magic-submit:hover {
+ background: #1A8CD8;
+}
+
+.beep-embed .beep-button.beep-magic-submit:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.beep-embed .beep-magic-success {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 12px;
+ background: rgba(0, 186, 124, 0.1);
+ border-radius: 8px;
+ color: #00BA7C;
+ font-size: 14px;
+}
+
+.beep-embed .beep-magic-error {
+ padding: 12px;
+ background: rgba(224, 36, 94, 0.1);
+ border-radius: 8px;
+ color: #F4212E;
+ font-size: 14px;
+ margin-top: 12px;
+}
+
+/* Public feed notice */
+.beep-embed .beep-public-notice {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 12px 16px;
+ background: #192734;
+ border-bottom: 1px solid #333F4D;
+ color: #71767B;
+ font-size: 14px;
+}
+
+.beep-embed .beep-public-notice a {
+ color: #1D9BF0;
+ text-decoration: none;
+}
+
+.beep-embed .beep-public-notice a:hover {
+ text-decoration: underline;
+}
+
+
+
+
+/* Bottom bar: wordmark left, delete right */
+/* Bottom bar: wordmark left, delete right */
+/* Bottom bar: delete left, wordmark right (blue, large) */
+/* Bottom bar: delete left, wordmark right (blue) */
+/* Bottom bar: delete left, wordmark text right */
+.beep-embed .beep-bottom-bar {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 4px;
+}
+.beep-embed .beep-wordmark {
+ font-size: 16px;
+ font-weight: 800;
+ color: #0064cd;
+ opacity: 0.5;
+ letter-spacing: -0.5px;
+ transition: opacity 0.15s ease;
+ user-select: none;
+}
+.beep-embed .beep-post:hover .beep-wordmark {
+ opacity: 1;
+}
diff --git a/assets/beep.js b/assets/beep.js
new file mode 100755
index 0000000..25c91e8
--- /dev/null
+++ b/assets/beep.js
@@ -0,0 +1,1126 @@
+/* Beep — Twitter-style microblog frontend */
+(function () {
+ 'use strict';
+
+ var config = window.BeepConfig || {};
+ var limit = (config.charLimit || 280) | 0;
+
+ document.addEventListener('DOMContentLoaded', init);
+
+ function init() {
+ var feeds = document.querySelectorAll('[data-beep-feed]');
+ Array.prototype.forEach.call(feeds, initFeed);
+ }
+
+ var feedState = { offset: 0, loading: false, hasMore: true, currentSort: 'latest' };
+
+ function initFeed(feed) {
+ var compose = feed.querySelector('[data-beep-compose]');
+ if (compose) initCompose(compose, feed);
+ initSortTabs(feed);
+ initMedia(compose, feed);
+ initInfiniteScroll(feed);
+ feed.addEventListener('click', onClick);
+ feed.addEventListener('input', onInput);
+ feed.addEventListener('keydown', onKeydown);
+ }
+
+ /* ---------- Infinite scroll ---------- */
+ function initInfiniteScroll(feed) {
+ var sentinel = document.createElement('div');
+ sentinel.className = 'beep-scroll-sentinel';
+ sentinel.setAttribute('data-beep-scroll-sentinel', '');
+ var list = feed.querySelector('[data-beep-list]');
+ if (list) list.appendChild(sentinel);
+
+ if (!('IntersectionObserver' in window)) return;
+ var observer = new IntersectionObserver(function(entries) {
+ if (entries[0].isIntersecting && !feedState.loading && feedState.hasMore) {
+ loadMore(feed);
+ }
+ }, { rootMargin: '400px' });
+ observer.observe(sentinel);
+ feed._beepScrollObserver = observer;
+ }
+
+ function loadMore(feed) {
+ feedState.loading = true;
+ var sentinel = feed.querySelector('[data-beep-scroll-sentinel]');
+ if (sentinel) sentinel.innerHTML = ' Loading...
';
+
+ fetch(config.restUrl + 'feed?sort=' + encodeURIComponent(feedState.currentSort) + '&offset=' + feedState.offset, {
+ credentials: 'same-origin',
+ headers: { 'X-WP-Nonce': config.nonce }
+ })
+ .then(handleResponse)
+ .then(function(data) {
+ feedState.hasMore = !!data.has_more;
+ feedState.offset = data.offset || feedState.offset + 50;
+ if (sentinel) sentinel.innerHTML = '';
+ if (data.html) {
+ var list = feed.querySelector('[data-beep-list]');
+ if (list) {
+ var tmp = document.createElement('div');
+ tmp.innerHTML = data.html;
+ while (tmp.firstElementChild) {
+ // Insert before the sentinel
+ list.insertBefore(tmp.firstElementChild, sentinel);
+ }
+ }
+ }
+ if (!feedState.hasMore && sentinel) {
+ sentinel.innerHTML = 'No more beeps
';
+ }
+ })
+ .catch(function() {
+ if (sentinel) sentinel.innerHTML = '';
+ })
+ .finally(function() { feedState.loading = false; });
+ }
+
+ /* ---------- Composer ---------- */
+ function initCompose(compose, feed) {
+ var input = compose.querySelector('[data-beep-input]');
+ var counter = compose.querySelector('[data-beep-counter]');
+ var button = compose.querySelector('[data-beep-submit]');
+ if (!input || !button) return;
+
+ function refresh() {
+ autosize(input);
+ var remaining = limit - countChars(input.value);
+ counter.textContent = remaining;
+ counter.classList.toggle('beep-counter-warn', remaining < 20 && remaining >= 0);
+ counter.classList.toggle('beep-counter-bad', remaining < 0);
+ button.disabled = input.value.trim() === '' || remaining < 0;
+ }
+ input.addEventListener('input', refresh);
+ refresh();
+
+ button.addEventListener('click', function () {
+ var content = input.value.trim();
+ if (!content) return;
+ button.disabled = true;
+ var quotedPost = compose._beepQuotedPost;
+ var gifUrl = compose._beepGifUrl;
+ var payload = { content: content };
+ if (quotedPost && quotedPost.id) payload.quote_id = quotedPost.id;
+ if (gifUrl) payload.gif_url = gifUrl;
+
+ fetch(config.restUrl + 'post', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce },
+ credentials: 'same-origin',
+ body: JSON.stringify(payload)
+ })
+ .then(handleResponse)
+ .then(function (data) {
+ var list = feed.querySelector('[data-beep-list]');
+ var empty = list && list.querySelector('.beep-empty');
+ if (empty) empty.remove();
+
+ // Reload feed to get the fully rendered post (includes media, quotes, polls)
+ reloadFeed(feed, 'recent');
+
+ input.value = '';
+ clearQuotePreview(compose);
+ if (compose._beepGifUrl) {
+ compose._beepGifUrl = null;
+ var gifPrev = compose.querySelector('[data-beep-gif-preview]');
+ if (gifPrev) { gifPrev.innerHTML = ''; gifPrev.hidden = true; }
+ }
+ refresh();
+
+ // Upload pending images and voice note to this new beep
+ var pending = compose._beepPendingImages;
+ var pendingVoice = compose._beepPendingVoice;
+ var uploadDone = function () {
+ reloadFeed(feed, 'latest');
+ };
+
+ var tasks = [];
+ if (pending && pending.length && data.id) {
+ tasks.push(uploadPendingImages(data.id, pending, compose));
+ }
+ if (pendingVoice && data.id) {
+ tasks.push(uploadPendingVoice(data.id, pendingVoice, compose));
+ }
+ // Create poll if poll data exists
+ if (data.id) {
+ tasks.push(createPoll(data.id, compose).then(function() {
+ clearPoll(compose);
+ }));
+ }
+
+ if (tasks.length) {
+ Promise.all(tasks).then(uploadDone);
+ } else {
+ // No uploads, just clear poll if present
+ clearPoll(compose);
+ }
+ })
+ .catch(function () {
+ showToast('Could not post. Please try again.', 'error');
+ button.disabled = false;
+ });
+ });
+ }
+
+ /* ---------- Sort tabs ---------- */
+ function initSortTabs(feed) {
+ var tabs = feed.querySelector('[data-beep-tabs]');
+ if (!tabs) return;
+ tabs.addEventListener('click', function (e) {
+ var btn = e.target.closest('[data-beep-sort]');
+ if (!btn) return;
+ e.preventDefault();
+ var sort = btn.dataset.beepSort;
+ tabs.querySelectorAll('[data-beep-sort]').forEach(function (b) {
+ b.classList.toggle('is-active', b === btn);
+ });
+ reloadFeed(feed, sort);
+ });
+ }
+
+ function reloadFeed(feed, sort) {
+ var list = feed.querySelector('[data-beep-list]');
+ if (!list) return;
+ list.classList.add('beep-loading');
+ feedState.offset = 0;
+ feedState.hasMore = true;
+ feedState.currentSort = sort;
+ fetch(config.restUrl + 'feed?sort=' + encodeURIComponent(sort), {
+ credentials: 'same-origin',
+ headers: { 'X-WP-Nonce': config.nonce }
+ })
+ .then(handleResponse)
+ .then(function (data) {
+ // Remove everything except the sentinel
+ var sentinel = list.querySelector('[data-beep-scroll-sentinel]');
+ list.innerHTML = '';
+ if (data.html) {
+ var tmp = document.createElement('div');
+ tmp.innerHTML = data.html;
+ while (tmp.firstElementChild) {
+ list.appendChild(tmp.firstElementChild);
+ }
+ } else {
+ list.innerHTML = '' + emptyIcon() + '
No beeps yet.
Be the first to share something! ';
+ }
+ if (sentinel) list.appendChild(sentinel);
+ feedState.hasMore = !!data.has_more;
+ feedState.offset = data.offset || 50;
+ list.classList.remove('beep-loading');
+ })
+ .catch(function () {
+ list.classList.remove('beep-loading');
+ });
+ }
+
+ function emptyIcon() {
+ return '';
+ }
+
+ /* ---------- Click delegation ---------- */
+ function onClick(e) {
+ var t = e.target;
+
+ var likePost = t.closest('[data-beep-like-post]');
+ if (likePost) { e.preventDefault(); if (requireLogin()) handleLike(likePost, 'like/' + likePost.dataset.beepLikePost); return; }
+
+ var likeReply = t.closest('[data-beep-like-reply]');
+ if (likeReply) { e.preventDefault(); if (requireLogin()) handleLike(likeReply, 'reply-like/' + likeReply.dataset.beepLikeReply); return; }
+
+ var replyToggle = t.closest('[data-beep-toggle-reply]');
+ if (replyToggle) {
+ e.preventDefault();
+ if (!requireLogin()) return;
+ var container = replyToggle.closest('[data-beep-post], [data-beep-reply]');
+ if (!container) return;
+ var form = container.querySelector(':scope > .beep-post-body > [data-beep-reply-form], :scope > .beep-reply-body > [data-beep-reply-form]');
+ if (!form) return;
+ form.hidden = !form.hidden;
+ if (!form.hidden) {
+ var ta = form.querySelector('textarea');
+ if (ta) ta.focus();
+ }
+ return;
+ }
+
+ var replyBtn = t.closest('[data-beep-reply-submit]');
+ if (replyBtn) { e.preventDefault(); handleReplySubmit(replyBtn); return; }
+
+ var shareBtn = t.closest('[data-beep-share]');
+ if (shareBtn) { e.preventDefault(); handleShare(shareBtn); return; }
+
+ var delPost = t.closest('[data-beep-delete]');
+ if (delPost) {
+ e.preventDefault();
+ handleDeleteWithUndo('delete/' + delPost.dataset.beepDelete, '[data-beep-post="' + delPost.dataset.beepDelete + '"]', 'beep');
+ return;
+ }
+
+ var delReply = t.closest('[data-beep-delete-reply]');
+ if (delReply) {
+ e.preventDefault();
+ handleDeleteWithUndo('delete-reply/' + delReply.dataset.beepDeleteReply, '[data-beep-reply="' + delReply.dataset.beepDeleteReply + '"]', 'reply');
+ return;
+ }
+
+ // Thread / conversation view
+ var threadBtn = t.closest('[data-beep-thread]');
+ if (threadBtn) { e.preventDefault(); openThread(threadBtn.dataset.beepThread); return; }
+
+ // Thread modal close
+ var threadClose = t.closest('[data-beep-thread-close]');
+ if (threadClose) { e.preventDefault(); closeThread(); return; }
+
+ // Quote button - open composer with quoted beep pre-loaded
+ var quoteBtn = t.closest('[data-beep-quote]');
+ if (quoteBtn) { e.preventDefault(); openComposerWithQuote(quoteBtn.dataset.beepQuote); return; }
+
+ // Quote composer tab toggle
+ var quoteComposerBtn = t.closest('[data-beep-quote-composer-btn]');
+ if (quoteComposerBtn) {
+ e.preventDefault();
+ var compose = quoteComposerBtn.closest('[data-beep-compose]');
+ var quoteSection = compose && compose.querySelector('[data-beep-compose-quote]');
+ if (quoteSection) quoteSection.hidden = !quoteSection.hidden;
+ return;
+ }
+
+ // Quote lookup - Add button
+ var quoteLookupBtn = t.closest('[data-beep-quote-lookup-btn]');
+ if (quoteLookupBtn) {
+ e.preventDefault();
+ var compose = quoteLookupBtn.closest('[data-beep-compose]');
+ var input = compose && compose.querySelector('[data-beep-quote-lookup]');
+ var query = input ? input.value.trim() : '';
+ if (!query) return;
+ fetch(config.restUrl + 'lookup?q=' + encodeURIComponent(query), {
+ headers: { 'X-WP-Nonce': config.nonce },
+ credentials: 'same-origin'
+ })
+ .then(handleResponse)
+ .then(function(quoted) {
+ if (compose) {
+ compose._beepQuotedPost = { id: quoted.id, data: quoted };
+ showQuotePreview(compose, quoted);
+ }
+ })
+ .catch(function() { showToast('Beep not found.', 'error'); });
+ return;
+ }
+
+ // Quote remove button
+ var quoteRemoveBtn = t.closest('[data-beep-quote-remove]');
+ if (quoteRemoveBtn) { e.preventDefault(); var compose = quoteRemoveBtn.closest('[data-beep-compose]'); clearQuotePreview(compose); return; }
+
+ // GIF picker button
+ var gifBtn = t.closest('[data-beep-gif-btn]');
+ if (gifBtn) { e.preventDefault(); var compose = gifBtn.closest('[data-beep-compose]'); openGifPicker(compose); return; }
+
+ // GIF modal close
+ var gifClose = t.closest('[data-beep-gif-close]');
+ if (gifClose) { e.preventDefault(); closeGifPicker(); return; }
+
+ // GIF search on Enter (handled in keydown)
+ // GIF search button
+ var gifSearchBtn = t.closest('[data-beep-gif-search-btn]');
+ if (gifSearchBtn) {
+ e.preventDefault();
+ var compose = gifSearchBtn.closest('[data-beep-compose]') || document.querySelector('[data-beep-compose]');
+ var input = gifSearchBtn.closest('.beep-gif-search-bar') && gifSearchBtn.closest('.beep-gif-search-bar').querySelector('[data-beep-gif-search]');
+ if (input) searchGifs(input.value, compose);
+ return;
+ }
+
+ // GIF remove
+ var gifRemoveBtn = t.closest('[data-beep-gif-remove]');
+ if (gifRemoveBtn) {
+ e.preventDefault();
+ var compose = gifRemoveBtn.closest('[data-beep-compose]');
+ if (compose) {
+ compose._beepGifUrl = null;
+ var preview = compose.querySelector('[data-beep-gif-preview]');
+ if (preview) { preview.innerHTML = ''; preview.hidden = true; }
+ }
+ return;
+ }
+
+ // Poll button - toggle poll section
+ var pollBtn = t.closest('[data-beep-poll-btn]');
+ if (pollBtn) {
+ e.preventDefault();
+ var compose = pollBtn.closest('[data-beep-compose]');
+ if (compose) openPollSection(compose);
+ return;
+ }
+
+ // Poll add option
+ var pollAddOptionBtn = t.closest('[data-beep-poll-add-option]');
+ if (pollAddOptionBtn) {
+ e.preventDefault();
+ var compose = pollAddOptionBtn.closest('[data-beep-compose]');
+ if (compose) addPollOption(compose);
+ return;
+ }
+
+ // Poll remove option
+ var pollRemoveOptionBtn = t.closest('[data-beep-poll-remove-option]');
+ if (pollRemoveOptionBtn) {
+ e.preventDefault();
+ var compose = pollRemoveOptionBtn.closest('[data-beep-compose]');
+ if (compose) removePollOption(compose);
+ return;
+ }
+
+ // Poll remove
+ var pollRemoveBtn = t.closest('[data-beep-poll-remove]');
+ if (pollRemoveBtn) {
+ e.preventDefault();
+ var compose = pollRemoveBtn.closest('[data-beep-compose]');
+ if (compose) clearPoll(compose);
+ return;
+ }
+
+ // Poll vote button
+ var pollVoteBtn = t.closest('[data-beep-poll-vote]');
+ if (pollVoteBtn) {
+ e.preventDefault();
+ if (requireLogin()) handlePollVote(pollVoteBtn);
+ return;
+ }
+
+ // Image lightbox - click on post images to view full size
+ var lightboxImg = t.closest('[data-beep-lightbox]');
+ if (lightboxImg) {
+ e.preventDefault();
+ openLightbox(lightboxImg.href || lightboxImg.querySelector('img').src);
+ return;
+ }
+
+ // Lightbox close
+ var lightboxClose = t.closest('.beep-lightbox');
+ if (lightboxClose) {
+ closeLightbox();
+ return;
+ }
+ }
+
+ function onInput(e) {
+ var input = e.target.closest('.beep-reply-input, .beep-compose-input');
+ if (!input) return;
+ autosize(input);
+ var counter = input.parentElement.querySelector('.beep-char-counter');
+ var submit = input.parentElement.querySelector('.beep-button');
+ if (counter) {
+ var remaining = limit - countChars(input.value);
+ counter.textContent = remaining;
+ counter.classList.toggle('beep-counter-warn', remaining < 20 && remaining >= 0);
+ counter.classList.toggle('beep-counter-bad', remaining < 0);
+ if (submit) submit.disabled = input.value.trim() === '' || remaining < 0;
+ }
+ }
+
+ function onKeydown(e) {
+ if (!((e.metaKey || e.ctrlKey) && e.key === 'Enter')) return;
+ var input = e.target.closest('textarea');
+ if (!input) return;
+ var wrap = input.closest('.beep-compose, .beep-reply-form');
+ if (!wrap) return;
+ var btn = wrap.querySelector('button');
+ if (btn && !btn.disabled) btn.click();
+ }
+
+ /* ---------- Like (works for both posts and replies) ---------- */
+ function handleLike(btn, urlFragment) {
+ var countEl = btn.querySelector('[data-beep-like-count]');
+ var was = btn.classList.contains('is-liked');
+
+ btn.classList.toggle('is-liked');
+ if (!was) {
+ btn.classList.add('beep-just-liked');
+ setTimeout(function () { btn.classList.remove('beep-just-liked'); }, 600);
+ }
+ if (countEl) {
+ var current = parseInt(countEl.textContent, 10) || 0;
+ var optimistic = current + (was ? -1 : 1);
+ countEl.textContent = optimistic > 0 ? optimistic : '';
+ }
+
+ fetch(config.restUrl + urlFragment, {
+ method: 'POST',
+ headers: { 'X-WP-Nonce': config.nonce },
+ credentials: 'same-origin'
+ })
+ .then(handleResponse)
+ .then(function (data) {
+ if (countEl) countEl.textContent = data.count > 0 ? data.count : '';
+ btn.classList.toggle('is-liked', !!data.liked);
+ })
+ .catch(function () {
+ btn.classList.toggle('is-liked', was);
+ });
+ }
+
+ /* ---------- Reply submit (top-level or nested) ---------- */
+ function handleReplySubmit(btn) {
+ var postId = btn.dataset.beepReplySubmit;
+ var parent = btn.dataset.beepReplyParent || '0';
+ var form = btn.closest('[data-beep-reply-form]');
+ var input = form && form.querySelector('textarea');
+ if (!input) return;
+ var content = input.value.trim();
+ if (!content) return;
+ btn.disabled = true;
+
+ fetch(config.restUrl + 'reply/' + postId, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce },
+ credentials: 'same-origin',
+ body: JSON.stringify({ content: content, parent: parseInt(parent, 10) || 0 })
+ })
+ .then(handleResponse)
+ .then(function (data) {
+ if (data.pending) {
+ form.innerHTML = 'Reply submitted, awaiting moderation.
';
+ return;
+ }
+ // Reload the full feed to get the properly rendered reply
+ var feed = btn.closest('[data-beep-feed]');
+ if (feed) {
+ reloadFeed(feed, 'latest');
+ }
+ })
+ .catch(function () {
+ showToast('Could not post reply. Please try again.', 'error');
+ })
+ .finally(function () { btn.disabled = false; });
+ }
+
+ /* ---------- Share (copy link) ---------- */
+ function handleShare(btn) {
+ var url = btn.dataset.beepShare;
+ if (!url || !navigator.clipboard) return;
+ navigator.clipboard.writeText(url).then(function () {
+ showToast('Link copied to clipboard', 'success');
+ });
+ }
+
+ /* ---------- Delete with undo toast ---------- */
+ function handleDeleteWithUndo(pathFragment, selector, type) {
+ var el = document.querySelector(selector);
+ if (!el) return;
+
+ // Store original state
+ var parent = el.parentNode;
+ var nextSibling = el.nextSibling;
+
+ // Hide the element with animation
+ el.style.transition = 'opacity 0.3s ease, max-height 0.3s ease';
+ el.style.opacity = '0';
+ el.style.maxHeight = '0';
+ el.style.overflow = 'hidden';
+
+ // Show undo toast
+ var toast = document.createElement('div');
+ toast.className = 'beep-toast beep-toast-undo';
+ toast.innerHTML = '' + (type === 'beep' ? 'Beep' : 'Reply') + ' deleted.';
+ document.body.appendChild(toast);
+
+ // Trigger animation
+ requestAnimationFrame(function() {
+ toast.classList.add('beep-toast-visible');
+ });
+
+ var deleteTimeout = setTimeout(function() {
+ // Actually delete
+ fetch(config.restUrl + pathFragment, {
+ method: 'DELETE',
+ headers: { 'X-WP-Nonce': config.nonce },
+ credentials: 'same-origin'
+ })
+ .then(handleResponse)
+ .then(function () {
+ if (el && el.parentNode) el.remove();
+ })
+ .catch(function () {
+ // Restore on failure
+ restoreElement(el, parent, nextSibling);
+ showToast('Could not delete.', 'error');
+ });
+ dismissToast(toast);
+ }, 4000);
+
+ // Undo button
+ toast.querySelector('.beep-toast-undo-btn').addEventListener('click', function() {
+ clearTimeout(deleteTimeout);
+ restoreElement(el, parent, nextSibling);
+ dismissToast(toast);
+ });
+ }
+
+ function restoreElement(el, parent, nextSibling) {
+ el.style.transition = '';
+ el.style.opacity = '';
+ el.style.maxHeight = '';
+ el.style.overflow = '';
+ if (nextSibling) {
+ parent.insertBefore(el, nextSibling);
+ } else {
+ parent.appendChild(el);
+ }
+ }
+
+ function dismissToast(toast) {
+ toast.classList.remove('beep-toast-visible');
+ setTimeout(function() { toast.remove(); }, 300);
+ }
+
+ /* ---------- Toast notification ---------- */
+ function showToast(message, type) {
+ var existing = document.querySelector('.beep-toast.beep-toast-msg');
+ if (existing) existing.remove();
+
+ var toast = document.createElement('div');
+ toast.className = 'beep-toast beep-toast-msg beep-toast-' + (type || 'info');
+ toast.textContent = message;
+ document.body.appendChild(toast);
+ requestAnimationFrame(function() {
+ toast.classList.add('beep-toast-visible');
+ });
+ setTimeout(function() { dismissToast(toast); }, 3000);
+ }
+
+ /* ---------- Helpers ---------- */
+ function requireLogin() {
+ if (config.isLoggedIn) return true;
+ if (config.loginUrl) window.location.href = config.loginUrl;
+ return false;
+ }
+
+ function autosize(el) {
+ el.style.height = 'auto';
+ el.style.height = el.scrollHeight + 'px';
+ }
+
+ function countChars(s) {
+ return Array.from(s).length;
+ }
+
+ function escapeHtml(str) {
+ var div = document.createElement('div');
+ div.appendChild(document.createTextNode(str));
+ return div.innerHTML;
+ }
+
+ /* ---------- Media upload (images) ---------- */
+ function initMedia(compose, feed) {
+ var input = compose && compose.querySelector('[data-beep-media-input]');
+ var preview = compose && compose.querySelector('[data-beep-media-preview]');
+ var counter = compose && compose.querySelector('[data-beep-media-count]');
+ var voiceInput = compose && compose.querySelector('[data-beep-voice-input]');
+ var voicePreview = compose && compose.querySelector('[data-beep-voice-preview]');
+ if (!input && !voiceInput) return;
+
+ var pendingImages = [];
+ var pendingVoice = null;
+
+ // Voice note input
+ if (voiceInput) {
+ voiceInput.addEventListener('change', function (e) {
+ var files = e.target.files;
+ if (!files || !files.length) return;
+ var file = files[0];
+ if (file.size > 20 * 1024 * 1024) {
+ showToast('Voice note must be under 20 MB.', 'error');
+ voiceInput.value = '';
+ return;
+ }
+ var accepted = ['audio/mpeg','audio/ogg','audio/wav','audio/webm','audio/mp4','audio/x-m4a'];
+ if (accepted.indexOf(file.type) === -1) {
+ showToast('Only MP3, OGG, WAV, WebM, and M4A audio are allowed.', 'error');
+ voiceInput.value = '';
+ return;
+ }
+ if (pendingVoice) {
+ showToast('Only one voice note per beep.', 'error');
+ voiceInput.value = '';
+ return;
+ }
+ var reader = new FileReader();
+ reader.onload = function (ev) {
+ var id = 'voice-' + Date.now();
+ pendingVoice = { file: file, id: id };
+ compose._beepPendingVoice = pendingVoice;
+ voicePreview.innerHTML = '';
+ voicePreview.hidden = false;
+ };
+ reader.readAsDataURL(file);
+ voiceInput.value = '';
+ });
+ }
+
+ // Voice remove button
+ if (voicePreview) {
+ voicePreview.addEventListener('click', function (e) {
+ var btn = e.target.closest('[data-beep-voice-remove]');
+ if (!btn) return;
+ pendingVoice = null;
+ if (compose) compose._beepPendingVoice = null;
+ voicePreview.innerHTML = '';
+ voicePreview.hidden = true;
+ });
+ }
+
+ if (input) {
+ input.addEventListener('change', function (e) {
+ var files = Array.prototype.slice.call(e.target.files);
+ if (!files.length) return;
+ var room = 4 - pendingImages.length;
+ if (room <= 0) { input.value = ''; return; }
+ files = files.slice(0, room);
+ files.forEach(function (file) {
+ if (file.size > 10 * 1024 * 1024) {
+ showToast('Image must be under 10 MB: ' + file.name, 'error');
+ return;
+ }
+ var reader = new FileReader();
+ reader.onload = function (ev) {
+ var url = ev.target.result;
+ var id = 'pending-' + Date.now() + '-' + Math.random().toString(36).substr(2, 6);
+ pendingImages.push({ file: file, previewUrl: url, id: id });
+ renderPreviews();
+ };
+ reader.readAsDataURL(file);
+ });
+ input.value = '';
+ });
+ }
+
+ function renderPreviews() {
+ if (!pendingImages.length) {
+ preview.innerHTML = '';
+ preview.hidden = true;
+ if (counter) counter.hidden = true;
+ return;
+ }
+ preview.hidden = false;
+ if (counter) {
+ counter.textContent = pendingImages.length + '/4';
+ counter.hidden = false;
+ }
+ preview.innerHTML = pendingImages.map(function (img) {
+ return '';
+ }).join('');
+ }
+
+ if (preview) {
+ preview.addEventListener('click', function (e) {
+ var btn = e.target.closest('[data-remove]');
+ if (!btn) return;
+ var id = btn.dataset.remove;
+ pendingImages = pendingImages.filter(function (img) { return img.id !== id; });
+ compose._beepPendingImages = pendingImages;
+ renderPreviews();
+ });
+ }
+
+ if (compose) {
+ compose._beepPendingImages = pendingImages;
+ compose._beepPendingVoice = pendingVoice;
+ }
+ }
+
+ function uploadPendingImages(postId, pendingImages, compose) {
+ if (!pendingImages || !pendingImages.length) return Promise.resolve();
+ var promises = pendingImages.map(function (img) {
+ var formData = new FormData();
+ formData.append('file', img.file);
+ return fetch(config.restUrl + 'media/' + postId, {
+ method: 'POST',
+ headers: { 'X-WP-Nonce': config.nonce },
+ credentials: 'same-origin',
+ body: formData
+ })
+ .then(handleResponse)
+ .catch(function (err) {
+ console.warn('Image upload failed:', img.file.name, err);
+ });
+ });
+ return Promise.all(promises).then(function () {
+ pendingImages.length = 0;
+ if (compose) {
+ var preview = compose.querySelector('[data-beep-media-preview]');
+ if (preview) { preview.innerHTML = ''; preview.hidden = true; }
+ var counter = compose.querySelector('[data-beep-media-count]');
+ if (counter) counter.hidden = true;
+ }
+ });
+ }
+
+ function uploadPendingVoice(postId, pendingVoice, compose) {
+ if (!pendingVoice) return Promise.resolve();
+ var formData = new FormData();
+ formData.append('file', pendingVoice.file);
+ return fetch(config.restUrl + 'voice/' + postId, {
+ method: 'POST',
+ headers: { 'X-WP-Nonce': config.nonce },
+ credentials: 'same-origin',
+ body: formData
+ })
+ .then(handleResponse)
+ .then(function () {
+ if (compose) {
+ compose._beepPendingVoice = null;
+ var vp = compose.querySelector('[data-beep-voice-preview]');
+ if (vp) { vp.innerHTML = ''; vp.hidden = true; }
+ }
+ })
+ .catch(function (err) {
+ console.warn('Voice upload failed:', err);
+ });
+ }
+
+ /* ---------- Thread / Conversation modal ---------- */
+ function openThread(postId) {
+ var modal = document.querySelector('[data-beep-thread-modal]');
+ var content = modal && modal.querySelector('[data-beep-thread-content]');
+ var loading = modal && modal.querySelector('.beep-thread-loading');
+ if (!modal || !content) return;
+ modal.hidden = false;
+ if (loading) loading.style.display = 'block';
+ content.innerHTML = '';
+
+ fetch(config.restUrl + 'thread/' + postId, {
+ headers: { 'X-WP-Nonce': config.nonce },
+ credentials: 'same-origin'
+ })
+ .then(handleResponse)
+ .then(function(data) {
+ if (loading) loading.style.display = 'none';
+ // data can be an array directly or { thread: [...] }
+ var thread = Array.isArray(data) ? data : (data.thread || []);
+ renderThread(thread, content);
+ })
+ .catch(function() {
+ if (loading) loading.textContent = 'Failed to load conversation.';
+ });
+ }
+
+ function closeThread() {
+ var modal = document.querySelector('[data-beep-thread-modal]');
+ if (modal) modal.hidden = true;
+ }
+
+ function renderThread(thread, container) {
+ if (!thread || !thread.length) {
+ container.innerHTML = 'No replies yet.
';
+ return;
+ }
+ var html = '';
+ thread.forEach(function(item) {
+ var indent = Math.min(item.depth, 5) * 24;
+ var author = item.author || {};
+ var avatar = escapeHtml(author.avatar || 'https://www.gravatar.com/avatar/?s=48&d=mp');
+ var name = escapeHtml(author.name || 'Unknown');
+ var username = escapeHtml(author.username || '');
+ var content_text = escapeHtml(item.content || '');
+ var time_ago = escapeHtml(item.time_ago || '');
+ var replyClass = item.depth > 0 ? 'beep-thread-reply' : 'beep-thread-root';
+ html += '';
+ html += '
';
+ html += '
';
+ html += '
';
+ html += '' + name + '';
+ html += '@' + username + '';
+ html += '' + time_ago + '';
+ html += '
';
+ html += '
' + content_text + '
';
+ html += '
';
+ html += '' + (item.reply_count || 0) + ' replies';
+ html += '' + (item.like_count || 0) + ' likes';
+ html += '
';
+ });
+ container.innerHTML = html;
+ }
+
+ /* ---------- Quote posts ---------- */
+ function openComposerWithQuote(quotedId) {
+ var compose = document.querySelector('[data-beep-compose]');
+ if (!compose) return;
+ var form = compose.querySelector('.beep-compose-body');
+ if (form) form.style.display = 'block';
+ compose.scrollIntoView({ behavior: 'smooth', block: 'center' });
+
+ fetch(config.restUrl + 'quote/' + quotedId, {
+ headers: { 'X-WP-Nonce': config.nonce },
+ credentials: 'same-origin'
+ })
+ .then(handleResponse)
+ .then(function(quoted) {
+ compose._beepQuotedPost = { id: quotedId, data: quoted };
+ showQuotePreview(compose, quoted);
+ })
+ .catch(function(err) { console.warn('Could not load quoted beep:', err); });
+ }
+
+ function showQuotePreview(compose, quoted) {
+ var preview = compose && compose.querySelector('[data-beep-quote-preview]');
+ var removeBtn = compose && compose.querySelector('[data-beep-quote-remove]');
+ if (!preview || !quoted) return;
+ var author = quoted.author || {};
+ var avatar = escapeHtml(author.avatar || 'https://www.gravatar.com/avatar/?s=48&d=mp');
+ var name = escapeHtml(author.name || 'Unknown');
+ var username = escapeHtml(author.username || '');
+ var time_ago = escapeHtml(quoted.time_ago || '');
+ var content = escapeHtml(quoted.content || '');
+ preview.innerHTML = '' +
+ '
' +
+ '
' +
+ '
' + name + ' ' +
+ '@' + username + ' ' +
+ '· ' +
+ '' + time_ago + '
' +
+ '
' + content + '
';
+ preview.hidden = false;
+ if (removeBtn) removeBtn.hidden = false;
+ }
+
+ function clearQuotePreview(compose) {
+ if (!compose) return;
+ compose._beepQuotedPost = null;
+ var preview = compose.querySelector('[data-beep-quote-preview]');
+ var removeBtn = compose.querySelector('[data-beep-quote-remove]');
+ if (preview) { preview.innerHTML = ''; preview.hidden = true; }
+ if (removeBtn) removeBtn.hidden = true;
+ }
+
+ /* ---------- GIF Picker (Giphy) ---------- */
+ function openGifPicker(compose) {
+ var modal = document.querySelector('[data-beep-gif-modal]');
+ if (!modal) return;
+ modal.hidden = false;
+ var searchInput = modal.querySelector('[data-beep-gif-search]');
+ if (searchInput) searchInput.value = '';
+ var grid = modal.querySelector('[data-beep-gif-grid]');
+ if (grid) grid.innerHTML = 'Type to search GIFs...
';
+ if (searchInput) searchInput.focus();
+ }
+
+ function closeGifPicker() {
+ var modal = document.querySelector('[data-beep-gif-modal]');
+ if (modal) modal.hidden = true;
+ }
+
+ function searchGifs(query, compose) {
+ if (!query || !query.trim()) return;
+ var apiKey = config.giphyKey || '';
+ if (!apiKey) {
+ console.warn('Beep: No Giphy API key configured.');
+ return;
+ }
+ var modal = document.querySelector('[data-beep-gif-modal]');
+ var grid = modal && modal.querySelector('[data-beep-gif-grid]');
+ if (!grid) return;
+ grid.innerHTML = 'Searching...
';
+
+ var url = 'https://api.giphy.com/v1/gifs/search?api_key=' + encodeURIComponent(apiKey) + '&q=' + encodeURIComponent(query) + '&limit=30&rating=g';
+
+ fetch(url)
+ .then(function(res) { return res.json(); })
+ .then(function(data) {
+ if (!data.data || !data.data.length) {
+ grid.innerHTML = 'No GIFs found.
';
+ return;
+ }
+ grid.innerHTML = '';
+ data.data.forEach(function(gif) {
+ var imgUrl = gif.images && gif.images.fixed_width_small && gif.images.fixed_width_small.url;
+ if (!imgUrl) return;
+ var item = document.createElement('div');
+ item.className = 'beep-gif-item';
+ var altText = escapeHtml(gif.title || '');
+ item.innerHTML = '
';
+ item.addEventListener('click', function() {
+ selectGif(gif, compose);
+ });
+ grid.appendChild(item);
+ });
+ })
+ .catch(function() {
+ grid.innerHTML = 'Search failed.
';
+ });
+ }
+
+ function selectGif(gif, compose) {
+ if (!compose) return;
+ var url = gif.images && gif.images.fixed_width && gif.images.fixed_width.url;
+ if (!url) return;
+ compose._beepGifUrl = url;
+ var preview = compose.querySelector('[data-beep-gif-preview]');
+ if (preview) {
+ preview.innerHTML = ' + ')
';
+ preview.hidden = false;
+ }
+ closeGifPicker();
+ }
+
+ /* ---------- Poll helpers ---------- */
+ function openPollSection(compose) {
+ var pollSection = compose.querySelector('[data-beep-compose-poll]');
+ if (!pollSection) return;
+ pollSection.hidden = false;
+ var questionInput = pollSection.querySelector('[data-beep-poll-question]');
+ if (questionInput) questionInput.focus();
+ var options = pollSection.querySelectorAll('[data-beep-poll-option]');
+ if (options.length < 2) {
+ while (options.length < 2) {
+ addPollOption(compose);
+ options = pollSection.querySelectorAll('[data-beep-poll-option]');
+ }
+ }
+ }
+
+ function addPollOption(compose) {
+ var pollSection = compose.querySelector('[data-beep-compose-poll]');
+ if (!pollSection) return;
+ var optionsContainer = pollSection.querySelector('[data-beep-poll-options]');
+ if (!optionsContainer) return;
+ var optionCount = optionsContainer.querySelectorAll('[data-beep-poll-option]').length;
+ if (optionCount >= 6) return;
+ var newRow = document.createElement('div');
+ newRow.className = 'beep-poll-option-row';
+ newRow.innerHTML = '';
+ optionsContainer.appendChild(newRow);
+ updatePollButtons(compose);
+ }
+
+ function removePollOption(compose) {
+ var pollSection = compose.querySelector('[data-beep-compose-poll]');
+ if (!pollSection) return;
+ var options = pollSection.querySelectorAll('[data-beep-poll-option]');
+ if (options.length <= 2) return;
+ var lastOption = options[options.length - 1];
+ if (lastOption) lastOption.parentElement.remove();
+ updatePollButtons(compose);
+ }
+
+ function updatePollButtons(compose) {
+ var pollSection = compose.querySelector('[data-beep-compose-poll]');
+ if (!pollSection) return;
+ var options = pollSection.querySelectorAll('[data-beep-poll-option]');
+ var addBtn = pollSection.querySelector('[data-beep-poll-add-option]');
+ var removeBtn = pollSection.querySelector('[data-beep-poll-remove-option]');
+ if (addBtn) addBtn.hidden = options.length >= 6;
+ if (removeBtn) removeBtn.hidden = options.length <= 2;
+ }
+
+ function clearPoll(compose) {
+ if (!compose) return;
+ compose._beepPoll = null;
+ var pollSection = compose.querySelector('[data-beep-compose-poll]');
+ if (pollSection) {
+ pollSection.hidden = true;
+ var question = pollSection.querySelector('[data-beep-poll-question]');
+ if (question) question.value = '';
+ var options = pollSection.querySelectorAll('[data-beep-poll-option]');
+ if (options.length > 2) {
+ for (var i = options.length - 1; i >= 2; i--) {
+ if (options[i].parentElement) options[i].parentElement.remove();
+ }
+ }
+ options = pollSection.querySelectorAll('[data-beep-poll-option]');
+ for (var j = 0; j < options.length; j++) {
+ options[j].value = '';
+ }
+ }
+ }
+
+ function collectPollData(compose) {
+ if (!compose) return null;
+ var pollSection = compose.querySelector('[data-beep-compose-poll]');
+ if (!pollSection || pollSection.hidden) return null;
+ var questionInput = pollSection.querySelector('[data-beep-poll-question]');
+ var question = questionInput ? questionInput.value.trim() : '';
+ if (!question) return null;
+ var optionInputs = pollSection.querySelectorAll('[data-beep-poll-option]');
+ var options = [];
+ for (var i = 0; i < optionInputs.length; i++) {
+ var val = optionInputs[i].value.trim();
+ if (val) options.push(val);
+ }
+ if (options.length < 2) return null;
+ return { question: question, options: options };
+ }
+
+ function createPoll(beepId, compose) {
+ var pollData = collectPollData(compose);
+ if (!pollData) return Promise.resolve();
+ return fetch(config.restUrl + 'poll/' + beepId, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce },
+ credentials: 'same-origin',
+ body: JSON.stringify(pollData)
+ })
+ .then(handleResponse)
+ .catch(function(err) {
+ console.warn('Poll creation failed:', err);
+ });
+ }
+
+ function handlePollVote(btn) {
+ var beepId = btn.dataset.beepPollBeep;
+ var optionIndex = parseInt(btn.dataset.beepPollVote, 10);
+ if (!beepId || isNaN(optionIndex)) return;
+ btn.disabled = true;
+ fetch(config.restUrl + 'poll/' + beepId + '/vote', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce },
+ credentials: 'same-origin',
+ body: JSON.stringify({ option_index: optionIndex })
+ })
+ .then(handleResponse)
+ .then(function(data) {
+ var postEl = btn.closest('[data-beep-post]');
+ if (postEl) {
+ var list = postEl.closest('[data-beep-list]');
+ if (list) reloadFeed(list.closest('[data-beep-feed]'), 'latest');
+ }
+ })
+ .catch(function() {
+ btn.disabled = false;
+ });
+ }
+
+ /* ---------- Image Lightbox ---------- */
+ function openLightbox(src) {
+ var existing = document.querySelector('.beep-lightbox');
+ if (existing) existing.remove();
+ var el = document.createElement('div');
+ el.className = 'beep-lightbox';
+ el.innerHTML = '
';
+ document.body.appendChild(el);
+ // Close on click outside image or Escape key
+ el.addEventListener('click', function(e) {
+ if (e.target === el || e.target.tagName !== 'IMG') closeLightbox();
+ });
+ var escHandler = function(e) {
+ if (e.key === 'Escape') { closeLightbox(); document.removeEventListener('keydown', escHandler); }
+ };
+ document.addEventListener('keydown', escHandler);
+ }
+
+ function closeLightbox() {
+ var el = document.querySelector('.beep-lightbox');
+ if (el) el.remove();
+ }
+
+ function handleResponse(res) {
+ if (!res.ok) return res.json().then(function (j) { throw j; }, function () { throw res; });
+ return res.json();
+ }
+})();
diff --git a/beep.php b/beep.php
new file mode 100755
index 0000000..a2288cd
--- /dev/null
+++ b/beep.php
@@ -0,0 +1,39 @@
+register(new \Elementor\Beep_Elementor_Widget());
+ }
+ }, 5);
+
+ // Admin settings (Giphy API key only — full settings via Beep_Settings)
+ add_action('admin_menu', [__CLASS__, 'add_admin_menu']);
+ add_action('admin_init', [__CLASS__, 'register_settings']);
+ }
+
+ public static function add_admin_menu() {
+ add_options_page(
+ 'Beep Settings',
+ 'Beep',
+ 'manage_options',
+ 'beep-settings',
+ [__CLASS__, 'render_settings_page']
+ );
+ }
+
+ public static function register_settings() {
+ register_setting('beep_settings_group', 'beep_giphy_key', [
+ 'sanitize_callback' => 'sanitize_text_field',
+ ]);
+ add_settings_section('beep_main_section', 'API Configuration', null, 'beep-settings');
+ add_settings_field(
+ 'beep_giphy_key',
+ 'Giphy API Key',
+ [__CLASS__, 'giphy_key_field'],
+ 'beep-settings',
+ 'beep_main_section',
+ ['label_for' => 'beep_giphy_key']
+ );
+ }
+
+ public static function giphy_key_field() {
+ $key = get_option('beep_giphy_key', '');
+ echo '';
+ echo 'Get a free API key at developers.giphy.com
';
+ }
+
+ public static function render_settings_page() {
+ if (!current_user_can('manage_options')) return;
+ echo 'Beep Settings
';
+ echo '
';
+ }
+
+ public static function activate() {
+ Beep_CPT::register();
+ Beep_Likes::create_table();
+ Beep_Polls::create_tables();
+ flush_rewrite_rules();
+ }
+
+ public static function deactivate() {
+ flush_rewrite_rules();
+ }
+
+ public static function enqueue_assets() {
+ wp_enqueue_style(
+ 'beep',
+ BEEP_URL . 'assets/beep.css',
+ [],
+ BEEP_VERSION
+ );
+ wp_enqueue_script(
+ 'beep',
+ BEEP_URL . 'assets/beep.js',
+ [],
+ BEEP_VERSION,
+ true
+ );
+ $giphy_key = get_option('beep_giphy_key', '');
+
+ wp_localize_script('beep', 'BeepConfig', [
+ 'restUrl' => esc_url_raw(rest_url('beep/v1/')),
+ 'nonce' => wp_create_nonce('wp_rest'),
+ 'charLimit' => BEEP_CHAR_LIMIT,
+ 'loginUrl' => esc_url_raw(wp_login_url(is_singular() ? get_permalink() : home_url('/'))),
+ 'isLoggedIn' => is_user_logged_in(),
+ 'giphyKey' => $giphy_key,
+ ]);
+ }
+}
diff --git a/includes/class-cpt.php b/includes/class-cpt.php
new file mode 100755
index 0000000..bc02a47
--- /dev/null
+++ b/includes/class-cpt.php
@@ -0,0 +1,48 @@
+ [
+ 'name' => 'Beeps',
+ 'singular_name' => 'Beep',
+ 'add_new' => 'New beep',
+ 'add_new_item' => 'Add new beep',
+ 'edit_item' => 'Edit beep',
+ 'view_item' => 'View beep',
+ 'all_items' => 'All beeps',
+ 'menu_name' => 'Beeps',
+ 'search_items' => 'Search beeps',
+ 'not_found' => 'No beeps found',
+ 'not_found_in_trash' => 'No beeps in trash',
+ ],
+ 'public' => true,
+ 'show_in_menu' => true,
+ 'show_in_rest' => true,
+ 'menu_icon' => 'data:image/png;base64,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',
+ 'menu_position' => 5,
+ 'supports' => ['editor', 'author', 'comments'],
+ 'has_archive' => false,
+ 'rewrite' => ['slug' => 'beeps'],
+ 'capability_type' => 'post',
+ ]);
+ }
+
+ public static function force_comments_open($open, $post_id) {
+ if (get_post_type($post_id) === self::POST_TYPE) {
+ return true;
+ }
+ return $open;
+ }
+}
diff --git a/includes/class-elementor-widget.php b/includes/class-elementor-widget.php
new file mode 100755
index 0000000..69e91e6
--- /dev/null
+++ b/includes/class-elementor-widget.php
@@ -0,0 +1,127 @@
+start_controls_section(
+ 'section_feed',
+ ['label' => 'Feed Settings']
+ );
+
+ $this->add_control(
+ 'header',
+ [
+ 'label' => 'Header Title',
+ 'type' => \Elementor\Controls_Manager::TEXT,
+ 'default' => 'Beep',
+ 'description' => 'Text shown in the feed header. Leave empty to show the wordmark logo.',
+ ]
+ );
+
+ $this->add_control(
+ 'show_wordmark',
+ [
+ 'label' => 'Show Wordmark Logo',
+ 'type' => \Elementor\Controls_Manager::SWITCHER,
+ 'default' => 'yes',
+ ]
+ );
+
+ $this->add_control(
+ 'sort',
+ [
+ 'label' => 'Default Sort',
+ 'type' => \Elementor\Controls_Manager::SELECT,
+ 'default' => 'latest',
+ 'options' => [
+ 'latest' => 'Latest First',
+ 'top' => 'Top / Most Liked',
+ ],
+ ]
+ );
+
+ $this->add_control(
+ 'limit',
+ [
+ 'label' => 'Posts Per Page',
+ 'type' => \Elementor\Controls_Manager::NUMBER,
+ 'min' => 1,
+ 'max' => 100,
+ 'default' => 20,
+ ]
+ );
+
+ $this->end_controls_section();
+
+ $this->start_controls_section(
+ 'section_style',
+ ['label' => 'Appearance']
+ );
+
+ $this->add_group_control(
+ \Elementor\Group_Control_Typography::get_type(),
+ [
+ 'name' => 'feed_typography',
+ 'label' => 'Feed Typography',
+ 'selector' => '{{WRAPPER}} .beep-feed',
+ ]
+ );
+
+ $this->add_control(
+ 'card_radius',
+ [
+ 'label' => 'Card Border Radius',
+ 'type' => \Elementor\Controls_Manager::SLIDER,
+ 'size_units' => ['px'],
+ 'range' => ['px' => ['min' => 0, 'max' => 32]],
+ 'default' => ['unit' => 'px', 'size' => 0],
+ 'selectors' => [
+ '{{WRAPPER}} .beep-post' => 'border-radius: {{SIZE}}{{UNIT}};',
+ ],
+ ]
+ );
+
+ $this->end_controls_section();
+ }
+
+ protected function render() {
+ $settings = $this->get_settings_for_display();
+
+ $atts = [
+ 'header' => $settings['header'] ?: 'Beep',
+ 'show_wordmark' => $settings['show_wordmark'] ? '1' : '0',
+ 'sort' => $settings['sort'],
+ 'limit' => (int) $settings['limit'],
+ ];
+
+ echo Beep_Shortcode::render_beep_feed($atts);
+ }
+}
\ No newline at end of file
diff --git a/includes/class-likes.php b/includes/class-likes.php
new file mode 100755
index 0000000..572ec0c
--- /dev/null
+++ b/includes/class-likes.php
@@ -0,0 +1,144 @@
+prefix . BEEP_LIKES_TABLE;
+ }
+
+ /**
+ * Create or migrate the likes table.
+ * The 'post_id' column historically only stored beep IDs; we now also
+ * use it for comment IDs (replies). 'object_type' distinguishes them.
+ */
+ public static function create_table() {
+ global $wpdb;
+ $table = self::table_name();
+ $charset = $wpdb->get_charset_collate();
+
+ $sql = "CREATE TABLE $table (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ object_type VARCHAR(20) NOT NULL DEFAULT 'post',
+ post_id BIGINT(20) UNSIGNED NOT NULL,
+ user_id BIGINT(20) UNSIGNED NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY object_user (object_type, post_id, user_id),
+ KEY post_id (post_id),
+ KEY user_id (user_id)
+ ) $charset;";
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ dbDelta($sql);
+
+ // Migration for installs from v1.0-1.3: add object_type column if missing.
+ $col = $wpdb->get_var($wpdb->prepare(
+ "SHOW COLUMNS FROM `$table` LIKE %s", 'object_type'
+ ));
+ if (!$col) {
+ $wpdb->query("ALTER TABLE `$table` ADD COLUMN object_type VARCHAR(20) NOT NULL DEFAULT 'post' AFTER id");
+ }
+ }
+
+ public static function register_routes() {
+ // Like a beep (post).
+ register_rest_route('beep/v1', '/like/(?P\d+)', [
+ 'methods' => 'POST',
+ 'callback' => [__CLASS__, 'toggle_post_like'],
+ 'permission_callback' => [__CLASS__, 'can_interact'],
+ ]);
+
+ // Like a reply (comment).
+ register_rest_route('beep/v1', '/reply-like/(?P\d+)', [
+ 'methods' => 'POST',
+ 'callback' => [__CLASS__, 'toggle_reply_like'],
+ 'permission_callback' => [__CLASS__, 'can_interact'],
+ ]);
+ }
+
+ public static function can_interact() {
+ return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
+ }
+
+ public static function toggle_post_like($request) {
+ $id = (int) $request['id'];
+ if (get_post_type($id) !== Beep_CPT::POST_TYPE) {
+ return new WP_Error('beep_not_a_beep', 'Not a beep.', ['status' => 400]);
+ }
+ return rest_ensure_response(self::toggle($id, 'post'));
+ }
+
+ public static function toggle_reply_like($request) {
+ $id = (int) $request['id'];
+ $c = get_comment($id);
+ if (!$c || get_post_type($c->comment_post_ID) !== Beep_CPT::POST_TYPE) {
+ return new WP_Error('beep_not_a_reply', 'Not a reply.', ['status' => 400]);
+ }
+ return rest_ensure_response(self::toggle($id, 'reply'));
+ }
+
+ /**
+ * Toggle a like for current user on the given object.
+ * @return array {liked: bool, count: int}
+ */
+ public static function toggle($object_id, $object_type) {
+ global $wpdb;
+ $table = self::table_name();
+ $user_id = get_current_user_id();
+
+ $existing = $wpdb->get_var($wpdb->prepare(
+ "SELECT id FROM $table WHERE object_type = %s AND post_id = %d AND user_id = %d",
+ $object_type, $object_id, $user_id
+ ));
+
+ if ($existing) {
+ $wpdb->delete($table, ['id' => $existing], ['%d']);
+ $liked = false;
+ } else {
+ $wpdb->insert($table, [
+ 'object_type' => $object_type,
+ 'post_id' => $object_id,
+ 'user_id' => $user_id,
+ 'created_at' => current_time('mysql'),
+ ], ['%s', '%d', '%d', '%s']);
+ $liked = true;
+ }
+
+ return [
+ 'liked' => $liked,
+ 'count' => self::get_count($object_id, $object_type),
+ ];
+ }
+
+ public static function get_count($object_id, $object_type = 'post') {
+ global $wpdb;
+ $table = self::table_name();
+ return (int) $wpdb->get_var($wpdb->prepare(
+ "SELECT COUNT(*) FROM $table WHERE object_type = %s AND post_id = %d",
+ $object_type, $object_id
+ ));
+ }
+
+ public static function user_has_liked($object_id, $object_type = 'post', $user_id = null) {
+ global $wpdb;
+ if (!$user_id) {
+ $user_id = get_current_user_id();
+ }
+ if (!$user_id) {
+ return false;
+ }
+ $table = self::table_name();
+ return (bool) $wpdb->get_var($wpdb->prepare(
+ "SELECT id FROM $table WHERE object_type = %s AND post_id = %d AND user_id = %d",
+ $object_type, $object_id, $user_id
+ ));
+ }
+}
diff --git a/includes/class-media.php b/includes/class-media.php
new file mode 100755
index 0000000..f2f97d3
--- /dev/null
+++ b/includes/class-media.php
@@ -0,0 +1,395 @@
+ self::$max_file_size) {
+ return new WP_Error('file_too_large', 'File exceeds 10 MB limit.');
+ }
+ $mime = mime_content_type($file['tmp_name']);
+ if (!in_array($mime, self::$image_types, true)) {
+ return new WP_Error('invalid_type', 'Only JPEG, PNG, GIF, and WebP are allowed.');
+ }
+
+ $existing = self::get_images($post_id);
+ if (count($existing) >= self::$max_images) {
+ return new WP_Error('too_many', 'Maximum ' . self::$max_images . ' images per beep.');
+ }
+
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+ require_once ABSPATH . 'wp-admin/includes/media.php';
+
+ $file_return = wp_handle_sideload($file, [
+ 'test_form' => false,
+ 'mimes' => [
+ 'jpg|jpeg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'gif' => 'image/gif',
+ 'webp' => 'image/webp',
+ ],
+ ]);
+
+ if (isset($file_return['error'])) {
+ return new WP_Error('sideload_error', $file_return['error']);
+ }
+
+ $attachment = [
+ 'post_mime_type' => $file_return['type'],
+ 'post_title' => preg_replace('/\.[^.]+$/', '', basename($file_return['file'])),
+ 'post_content' => '',
+ 'post_status' => 'inherit',
+ 'guid' => $file_return['url'],
+ ];
+
+ $attachment_id = wp_insert_attachment($attachment, $file_return['file'], $post_id);
+ if (is_wp_error($attachment_id)) {
+ return $attachment_id;
+ }
+
+ $attach_data = wp_generate_attachment_metadata($attachment_id, $file_return['file']);
+ wp_update_attachment_metadata($attachment_id, $attach_data);
+
+ add_post_meta($post_id, self::META_KEY_IMAGES, $attachment_id);
+
+ return [
+ 'id' => $attachment_id,
+ 'url' => wp_get_attachment_url($attachment_id),
+ 'thumb' => self::get_image_url($attachment_id, 'thumbnail'),
+ 'sizes' => self::get_image_sizes($attachment_id),
+ ];
+ }
+
+ /**
+ * Remove a single image from a beep post.
+ */
+ public static function remove_image($post_id, $attachment_id) {
+ $ids = self::get_images($post_id);
+ $ids = array_values(array_filter($ids, fn($id) => (int) $id !== (int) $attachment_id));
+ delete_post_meta($post_id, self::META_KEY_IMAGES);
+ foreach ($ids as $id) {
+ add_post_meta($post_id, self::META_KEY_IMAGES, $id);
+ }
+ wp_delete_attachment($attachment_id, true);
+ }
+
+ /**
+ * Get image attachment IDs for a beep post.
+ */
+ public static function get_images($post_id) {
+ $ids = get_post_meta($post_id, self::META_KEY_IMAGES, false);
+ return array_filter(array_map('intval', (array) $ids));
+ }
+
+ /**
+ * Get image srcset-ready sizes for an attachment.
+ */
+ public static function get_image_sizes($attachment_id) {
+ $sizes = [];
+ foreach (['thumbnail', 'medium', 'large', 'full'] as $size) {
+ $img = wp_get_attachment_image_src($attachment_id, $size);
+ if ($img) {
+ $sizes[$size] = ['url' => $img[0], 'width' => $img[1], 'height' => $img[2]];
+ }
+ }
+ return $sizes;
+ }
+
+ /**
+ * Get image URL at a given size.
+ */
+ public static function get_image_url($attachment_id, $size = 'medium') {
+ $img = wp_get_attachment_image_src($attachment_id, $size);
+ return $img ? $img[0] : '';
+ }
+
+ /**
+ * Set a video URL for a beep post.
+ */
+ public static function set_video_url($post_id, $url) {
+ $url = esc_url_raw($url);
+ if (!$url) return new WP_Error('invalid_url', 'Invalid video URL.');
+ update_post_meta($post_id, self::META_KEY_VIDEO, $url);
+ return true;
+ }
+
+ /**
+ * Get the video URL for a beep post.
+ */
+ public static function get_video_url($post_id) {
+ return esc_url_raw(get_post_meta($post_id, self::META_KEY_VIDEO, true));
+ }
+
+ /**
+ * Detect if a URL is a supported video oEmbed.
+ */
+ public static function detect_video_url($url) {
+ $patterns = [
+ 'youtube' => '#https?://(?:www\.)?youtube\.com/watch\?v=[^\s]+#',
+ 'yt_s' => '#https?://youtu\.be/[^\s]+#',
+ 'vimeo' => '#https?://vimeo\.com/\d+#',
+ 'twitter' => '#https?://(?:twitter\.com|x\.com)/[^\s#/]+/status/\d+#',
+ ];
+ foreach ($patterns as $name => $pattern) {
+ if (preg_match($pattern, $url)) return $name;
+ }
+ return false;
+ }
+
+ /**
+ * Render the Twitter-style image gallery.
+ *
+ * @param int[] $image_ids
+ * @return string HTML
+ */
+ public static function render_image_gallery($image_ids) {
+ if (empty($image_ids)) return '';
+
+ $count = count($image_ids);
+ $grid_class = 'beep-gallery';
+ if ($count === 1) $grid_class .= ' beep-gallery-1';
+ elseif ($count === 2) $grid_class .= ' beep-gallery-2';
+ elseif ($count === 3) $grid_class .= ' beep-gallery-3';
+ else $grid_class .= ' beep-gallery-4';
+
+ $html = '';
+ foreach ($image_ids as $i => $id) {
+ $full = self::get_image_url($id, 'large');
+ $thumb = self::get_image_url($id, 'medium');
+ if (!$thumb) $thumb = $full;
+ if (!$full) continue;
+ $alt = get_post_meta($id, '_wp_attachment_image_alt', true);
+ $html .= '
';
+ }
+ $html .= '
';
+ return $html;
+ }
+
+ /**
+ * Render video embed via oEmbed.
+ */
+ public static function render_video_embed($url) {
+ if (!$url) return '';
+ $embed = wp_oembed_get($url, ['width' => 560, 'height' => 315]);
+ if ($embed) {
+ return '' . $embed . '
';
+ }
+ return '';
+ }
+
+ /**
+ * Delete all media for a beep post (when beep is deleted).
+ */
+ public static function delete_media($post_id) {
+ foreach (self::get_images($post_id) as $id) {
+ wp_delete_attachment($id, true);
+ }
+ delete_post_meta($post_id, self::META_KEY_IMAGES);
+ delete_post_meta($post_id, self::META_KEY_VIDEO);
+ }
+ // Voice note support
+ private static $audio_types = ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm', 'audio/mp4', 'audio/x-m4a'];
+ private static $max_audio_size = 20 * 1024 * 1024; // 20 MB
+
+ public static function handle_voice_upload($post_id, $file) {
+ if (!is_array($file) || $file['error'] !== UPLOAD_ERR_OK) {
+ return new WP_Error('upload_error', 'Upload failed.');
+ }
+ if ($file['size'] > self::$max_audio_size) {
+ return new WP_Error('file_too_large', 'Voice note exceeds 20 MB limit.');
+ }
+ $mime = mime_content_type($file['tmp_name']);
+ if (!in_array($mime, self::$audio_types, true)) {
+ return new WP_Error('invalid_type', 'Only MP3, OGG, WAV, WebM, and M4A audio are allowed.');
+ }
+
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/media.php';
+
+ $file_return = wp_handle_upload($file, [
+ 'test_form' => false,
+ 'mimes' => [
+ 'mp3' => 'audio/mpeg',
+ 'ogg' => 'audio/ogg',
+ 'wav' => 'audio/wav',
+ 'webm' => 'audio/webm',
+ 'm4a' => 'audio/mp4',
+ ],
+ ]);
+
+ if (isset($file_return['error'])) {
+ return new WP_Error('upload_error', $file_return['error']);
+ }
+
+ $attachment = [
+ 'post_mime_type' => $file_return['type'],
+ 'post_title' => preg_replace('/\.[^.]+$/', '', basename($file_return['file'])),
+ 'post_content' => '',
+ 'post_status' => 'inherit',
+ 'guid' => $file_return['url'],
+ ];
+
+ $attachment_id = wp_insert_attachment($attachment, $file_return['file'], $post_id);
+ if (is_wp_error($attachment_id)) {
+ return $attachment_id;
+ }
+
+ $attach_data = wp_generate_attachment_metadata($attachment_id, $file_return['file']);
+ wp_update_attachment_metadata($attachment_id, $attach_data);
+
+ update_post_meta($post_id, 'beep_voice_url', wp_get_attachment_url($attachment_id));
+ update_post_meta($post_id, 'beep_voice_id', $attachment_id);
+
+ return [
+ 'id' => $attachment_id,
+ 'url' => wp_get_attachment_url($attachment_id),
+ ];
+ }
+
+ public static function get_voice_url($post_id) {
+ return esc_url_raw(get_post_meta($post_id, 'beep_voice_url', true));
+ }
+
+ public static function remove_voice($post_id) {
+ $id = (int) get_post_meta($post_id, 'beep_voice_id', true);
+ if ($id) {
+ wp_delete_attachment($id, true);
+ }
+ delete_post_meta($post_id, 'beep_voice_url');
+ delete_post_meta($post_id, 'beep_voice_id');
+ }
+
+ public static function render_voice_note($post_id) {
+ $url = self::get_voice_url($post_id);
+ if (!$url) return '';
+ $duration = '';
+ $meta = get_post_meta($post_id, 'beep_voice_id', true);
+ if ($meta) {
+ $metadata = wp_get_attachment_metadata($meta);
+ if ($metadata && isset($metadata['length_formatted'])) {
+ $duration = $metadata['length_formatted'];
+ }
+ }
+ $dur_html = $duration ? '' . esc_html($duration) . '' : '';
+ return '' .
+ '
' .
+ '
' .
+ $dur_html .
+ '
';
+ }
+
+ // Video file upload support
+ private static $video_types = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
+ private static $max_video_size = 100 * 1024 * 1024; // 100 MB
+
+ public static function handle_video_upload($post_id, $file) {
+ if (!is_array($file) || $file['error'] !== UPLOAD_ERR_OK) {
+ return new WP_Error('upload_error', 'Upload failed.');
+ }
+ if ($file['size'] > self::$max_video_size) {
+ return new WP_Error('file_too_large', 'Video exceeds 100 MB limit.');
+ }
+ $mime = mime_content_type($file['tmp_name']);
+ if (!in_array($mime, self::$video_types, true)) {
+ return new WP_Error('invalid_type', 'Only MP4, WebM, OGG, and MOV are allowed.');
+ }
+
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/media.php';
+
+ $file_return = wp_handle_upload($file, [
+ 'test_form' => false,
+ 'mimes' => [
+ 'mp4' => 'video/mp4',
+ 'webm' => 'video/webm',
+ 'ogg' => 'video/ogg',
+ 'mov' => 'video/quicktime',
+ ],
+ ]);
+
+ if (isset($file_return['error'])) {
+ return new WP_Error('upload_error', $file_return['error']);
+ }
+
+ $attachment = [
+ 'post_mime_type' => $file_return['type'],
+ 'post_title' => preg_replace('/\.[^.]+$/', '', basename($file_return['file'])),
+ 'post_content' => '',
+ 'post_status' => 'inherit',
+ 'guid' => $file_return['url'],
+ ];
+
+ $attachment_id = wp_insert_attachment($attachment, $file_return['file'], $post_id);
+ if (is_wp_error($attachment_id)) {
+ return $attachment_id;
+ }
+
+ $attach_data = wp_generate_attachment_metadata($attachment_id, $file_return['file']);
+ wp_update_attachment_metadata($attachment_id, $attach_data);
+
+ update_post_meta($post_id, 'beep_video_url', wp_get_attachment_url($attachment_id));
+ update_post_meta($post_id, 'beep_video_id', $attachment_id);
+
+ return [
+ 'id' => $attachment_id,
+ 'url' => wp_get_attachment_url($attachment_id),
+ ];
+ }
+
+ public static function get_upload_video_url($post_id) {
+ return esc_url_raw(get_post_meta($post_id, 'beep_video_url', true));
+ }
+
+ public static function remove_video($post_id) {
+ $id = (int) get_post_meta($post_id, 'beep_video_id', true);
+ if ($id) {
+ wp_delete_attachment($id, true);
+ }
+ delete_post_meta($post_id, 'beep_video_url');
+ delete_post_meta($post_id, 'beep_video_id');
+ }
+
+ public static function render_video_player($post_id) {
+ $url = self::get_upload_video_url($post_id);
+ if (!$url) return '';
+ return '' .
+ '
';
+ }
+
+}
\ No newline at end of file
diff --git a/includes/class-moderation.php b/includes/class-moderation.php
new file mode 100755
index 0000000..e2c8458
--- /dev/null
+++ b/includes/class-moderation.php
@@ -0,0 +1,50 @@
+ID) {
+ return;
+ }
+ $can_ban = current_user_can('edit_users');
+ $banned = get_user_meta($user->ID, self::BAN_META_KEY, true) === '1';
+ ?>
+ Beep moderation
+
+ prefix . self::TABLE_NAME;
+ }
+
+ public static function votes_table_name() {
+ global $wpdb;
+ return $wpdb->prefix . self::VOTES_TABLE;
+ }
+
+ /**
+ * Create the polls and poll_votes tables.
+ */
+ public static function create_tables() {
+ global $wpdb;
+ $polls = self::table_name();
+ $votes = self::votes_table_name();
+ $charset = $wpdb->get_charset_collate();
+
+ $polls_sql = "CREATE TABLE $polls (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ beep_id BIGINT(20) UNSIGNED NOT NULL,
+ question TEXT NOT NULL,
+ options TEXT NOT NULL,
+ votes TEXT NOT NULL DEFAULT '[]',
+ total_votes BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
+ ends_at DATETIME NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY beep_id (beep_id)
+ ) $charset;";
+
+ $votes_sql = "CREATE TABLE $votes (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ poll_id BIGINT(20) UNSIGNED NOT NULL,
+ user_id BIGINT(20) UNSIGNED NOT NULL,
+ option_index INT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY poll_user (poll_id, user_id),
+ KEY poll_id (poll_id),
+ KEY user_id (user_id)
+ ) $charset;";
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ dbDelta($polls_sql);
+ dbDelta($votes_sql);
+ }
+
+ public static function register_routes() {
+ // Create a poll for a beep
+ register_rest_route('beep/v1', '/poll/(?P\d+)', [
+ 'methods' => 'POST',
+ 'callback' => [__CLASS__, 'create_poll'],
+ 'permission_callback' => [__CLASS__, 'can_create_poll'],
+ ]);
+
+ // Get poll data for a beep
+ register_rest_route('beep/v1', '/poll/(?P\d+)', [
+ 'methods' => 'GET',
+ 'callback' => [__CLASS__, 'get_poll'],
+ 'permission_callback' => '__return_true',
+ ]);
+
+ // Vote on a poll
+ register_rest_route('beep/v1', '/poll/(?P\d+)/vote', [
+ 'methods' => 'POST',
+ 'callback' => [__CLASS__, 'vote'],
+ 'permission_callback' => [__CLASS__, 'can_vote'],
+ ]);
+ }
+
+ public static function can_create_poll() {
+ return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
+ }
+
+ public static function can_vote() {
+ return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
+ }
+
+ /**
+ * Create a poll for a beep post.
+ * Expects JSON body: { "question": "...", "options": ["a", "b", ...], "ends_at": "YYYY-MM-DDTHH:MM:SS" }
+ */
+ public static function create_poll($request) {
+ $beep_id = (int) $request['beep_id'];
+ $post = get_post($beep_id);
+
+ if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
+ return new WP_Error('not_found', 'Beep not found.', ['status' => 404]);
+ }
+
+ if (!current_user_can('edit_post', $beep_id)) {
+ return new WP_Error('forbidden', 'Cannot create poll for this beep.', ['status' => 403]);
+ }
+
+ // Check if poll already exists
+ if (self::poll_exists($beep_id)) {
+ return new WP_Error('poll_exists', 'A poll already exists for this beep.', ['status' => 400]);
+ }
+
+ $body = $request->get_json_params();
+ $question = sanitize_text_field($body['question'] ?? '');
+ $options = $body['options'] ?? [];
+ $ends_at = isset($body['ends_at']) ? sanitize_text_field($body['ends_at']) : null;
+
+ if (empty($question)) {
+ return new WP_Error('missing_question', 'Poll question is required.', ['status' => 400]);
+ }
+
+ if (count($options) < 2) {
+ return new WP_Error('too_few_options', 'Poll must have at least 2 options.', ['status' => 400]);
+ }
+
+ if (count($options) > 6) {
+ return new WP_Error('too_many_options', 'Poll can have at most 6 options.', ['status' => 400]);
+ }
+
+ // Sanitize options
+ $options = array_map('sanitize_text_field', $options);
+ $votes = array_fill(0, count($options), 0);
+
+ global $wpdb;
+ $table = self::table_name();
+
+ $result = $wpdb->insert($table, [
+ 'beep_id' => $beep_id,
+ 'question' => $question,
+ 'options' => json_encode($options),
+ 'votes' => json_encode($votes),
+ 'total_votes' => 0,
+ 'ends_at' => $ends_at ? date('Y-m-d H:i:s', strtotime($ends_at)) : null,
+ 'created_at' => current_time('mysql'),
+ ], ['%d', '%s', '%s', '%s', '%d', '%s', '%s']);
+
+ if ($result === false) {
+ return new WP_Error('db_error', 'Failed to create poll.', ['status' => 500]);
+ }
+
+ $poll_id = $wpdb->insert_id;
+
+ // Store poll_id in post meta for easy lookup
+ update_post_meta($beep_id, 'beep_poll_id', $poll_id);
+
+ return rest_ensure_response([
+ 'id' => $poll_id,
+ 'beep_id' => $beep_id,
+ 'question' => $question,
+ 'options' => $options,
+ 'votes' => $votes,
+ 'total_votes' => 0,
+ 'ends_at' => $ends_at,
+ ]);
+ }
+
+ /**
+ * Get poll data for a beep.
+ */
+ public static function get_poll($request) {
+ $beep_id = (int) $request['beep_id'];
+ $poll = self::get_poll_by_beep_id($beep_id);
+
+ if (!$poll) {
+ return new WP_Error('not_found', 'Poll not found.', ['status' => 404]);
+ }
+
+ $user_id = get_current_user_id();
+ $user_vote = null;
+ if ($user_id) {
+ $user_vote = self::get_user_vote($poll['id'], $user_id);
+ }
+
+ return rest_ensure_response([
+ 'id' => $poll['id'],
+ 'beep_id' => $poll['beep_id'],
+ 'question' => $poll['question'],
+ 'options' => $poll['options'],
+ 'votes' => $poll['votes'],
+ 'total_votes' => $poll['total_votes'],
+ 'ends_at' => $poll['ends_at'],
+ 'has_ended' => $poll['ends_at'] ? (strtotime($poll['ends_at']) < time()) : false,
+ 'user_vote' => $user_vote,
+ ]);
+ }
+
+ /**
+ * Vote on a poll.
+ * Expects JSON body: { "option_index": 0 }
+ */
+ public static function vote($request) {
+ $beep_id = (int) $request['beep_id'];
+ $poll = self::get_poll_by_beep_id($beep_id);
+
+ if (!$poll) {
+ return new WP_Error('not_found', 'Poll not found.', ['status' => 404]);
+ }
+
+ // Check if poll has ended
+ if ($poll['ends_at'] && strtotime($poll['ends_at']) < time()) {
+ return new WP_Error('poll_ended', 'This poll has ended.', ['status' => 400]);
+ }
+
+ $body = $request->get_json_params();
+ $option_index = (int) ($body['option_index'] ?? -1);
+
+ if ($option_index < 0 || $option_index >= count($poll['options'])) {
+ return new WP_Error('invalid_option', 'Invalid option index.', ['status' => 400]);
+ }
+
+ $user_id = get_current_user_id();
+
+ // Check if user already voted
+ $existing_vote = self::get_user_vote($poll['id'], $user_id);
+ if ($existing_vote !== null) {
+ // If same option, un-vote (toggle)
+ if ($existing_vote === $option_index) {
+ return rest_ensure_response(self::remove_vote($poll['id'], $user_id, $option_index, $beep_id));
+ }
+ // Different option - change vote
+ return rest_ensure_response(self::change_vote($poll['id'], $user_id, $existing_vote, $option_index, $beep_id));
+ }
+
+ // Cast new vote
+ return rest_ensure_response(self::cast_vote($poll['id'], $user_id, $option_index, $beep_id));
+ }
+
+ private static function cast_vote($poll_id, $user_id, $option_index, $beep_id) {
+ global $wpdb;
+ $polls_table = self::table_name();
+ $votes_table = self::votes_table_name();
+
+ // Insert vote
+ $wpdb->insert($votes_table, [
+ 'poll_id' => $poll_id,
+ 'user_id' => $user_id,
+ 'option_index' => $option_index,
+ 'created_at' => current_time('mysql'),
+ ], ['%d', '%d', '%d', '%s']);
+
+ // Update poll votes and total
+ $poll = self::get_poll_by_id($poll_id);
+ $votes = $poll['votes'];
+ $votes[$option_index]++;
+ $total_votes = $poll['total_votes'] + 1;
+
+ $wpdb->update(
+ $polls_table,
+ ['votes' => json_encode($votes), 'total_votes' => $total_votes],
+ ['id' => $poll_id],
+ ['%s', '%d'],
+ ['%d']
+ );
+
+ return [
+ 'voted' => true,
+ 'option_index' => $option_index,
+ 'votes' => $votes,
+ 'total_votes' => $total_votes,
+ 'user_vote' => $option_index,
+ ];
+ }
+
+ private static function remove_vote($poll_id, $user_id, $option_index, $beep_id) {
+ global $wpdb;
+ $polls_table = self::table_name();
+ $votes_table = self::votes_table_name();
+
+ // Delete vote
+ $wpdb->delete($votes_table, [
+ 'poll_id' => $poll_id,
+ 'user_id' => $user_id,
+ ], ['%d', '%d']);
+
+ // Update poll votes and total
+ $poll = self::get_poll_by_id($poll_id);
+ $votes = $poll['votes'];
+ $votes[$option_index] = max(0, $votes[$option_index] - 1);
+ $total_votes = max(0, $poll['total_votes'] - 1);
+
+ $wpdb->update(
+ $polls_table,
+ ['votes' => json_encode($votes), 'total_votes' => $total_votes],
+ ['id' => $poll_id],
+ ['%s', '%d'],
+ ['%d']
+ );
+
+ return [
+ 'voted' => false,
+ 'option_index' => null,
+ 'votes' => $votes,
+ 'total_votes' => $total_votes,
+ 'user_vote' => null,
+ ];
+ }
+
+ private static function change_vote($poll_id, $user_id, $old_index, $new_index, $beep_id) {
+ global $wpdb;
+ $polls_table = self::table_name();
+ $votes_table = self::votes_table_name();
+
+ // Update vote
+ $wpdb->update(
+ $votes_table,
+ ['option_index' => $new_index],
+ ['poll_id' => $poll_id, 'user_id' => $user_id],
+ ['%d'],
+ ['%d', '%d']
+ );
+
+ // Update poll votes
+ $poll = self::get_poll_by_id($poll_id);
+ $votes = $poll['votes'];
+ $votes[$old_index] = max(0, $votes[$old_index] - 1);
+ $votes[$new_index]++;
+
+ $wpdb->update(
+ $polls_table,
+ ['votes' => json_encode($votes)],
+ ['id' => $poll_id],
+ ['%s'],
+ ['%d']
+ );
+
+ return [
+ 'voted' => true,
+ 'option_index' => $new_index,
+ 'votes' => $votes,
+ 'total_votes' => $poll['total_votes'],
+ 'user_vote' => $new_index,
+ ];
+ }
+
+ /**
+ * Get poll by beep ID.
+ */
+ public static function get_poll_by_beep_id($beep_id) {
+ // First check post meta for poll_id
+ $poll_id = get_post_meta($beep_id, 'beep_poll_id', true);
+ if ($poll_id) {
+ return self::get_poll_by_id($poll_id);
+ }
+
+ // Fallback to table lookup
+ global $wpdb;
+ $table = self::table_name();
+ $row = $wpdb->get_row($wpdb->prepare(
+ "SELECT * FROM $table WHERE beep_id = %d",
+ $beep_id
+ ), ARRAY_A);
+
+ if (!$row) {
+ return null;
+ }
+
+ return self::format_poll($row);
+ }
+
+ /**
+ * Get poll by ID.
+ */
+ public static function get_poll_by_id($poll_id) {
+ global $wpdb;
+ $table = self::table_name();
+ $row = $wpdb->get_row($wpdb->prepare(
+ "SELECT * FROM $table WHERE id = %d",
+ $poll_id
+ ), ARRAY_A);
+
+ if (!$row) {
+ return null;
+ }
+
+ return self::format_poll($row);
+ }
+
+ /**
+ * Check if poll exists for a beep.
+ */
+ public static function poll_exists($beep_id) {
+ return self::get_poll_by_beep_id($beep_id) !== null;
+ }
+
+ /**
+ * Get user's vote for a poll.
+ */
+ public static function get_user_vote($poll_id, $user_id) {
+ global $wpdb;
+ $table = self::votes_table_name();
+ $row = $wpdb->get_row($wpdb->prepare(
+ "SELECT option_index FROM $table WHERE poll_id = %d AND user_id = %d",
+ $poll_id, $user_id
+ ));
+
+ return $row ? (int) $row->option_index : null;
+ }
+
+ /**
+ * Format poll row from database.
+ */
+ private static function format_poll($row) {
+ return [
+ 'id' => (int) $row['id'],
+ 'beep_id' => (int) $row['beep_id'],
+ 'question' => $row['question'],
+ 'options' => json_decode($row['options'], true),
+ 'votes' => json_decode($row['votes'], true),
+ 'total_votes' => (int) $row['total_votes'],
+ 'ends_at' => $row['ends_at'],
+ 'created_at' => $row['created_at'],
+ ];
+ }
+
+ /**
+ * Get poll results with percentages (for display).
+ */
+ public static function get_results_with_percentages($poll) {
+ $total = $poll['total_votes'];
+ $results = [];
+ foreach ($poll['options'] as $i => $option) {
+ $votes = $poll['votes'][$i] ?? 0;
+ $percentage = $total > 0 ? round(($votes / $total) * 100) : 0;
+ $results[] = [
+ 'index' => $i,
+ 'option' => $option,
+ 'votes' => $votes,
+ 'percentage' => $percentage,
+ ];
+ }
+ return $results;
+ }
+}
diff --git a/includes/class-quotes.php b/includes/class-quotes.php
new file mode 100755
index 0000000..8ceb15d
--- /dev/null
+++ b/includes/class-quotes.php
@@ -0,0 +1,102 @@
+post_type !== Beep_CPT::POST_TYPE) {
+ return new WP_Error('invalid_quoted', 'Cannot quote that beep.');
+ }
+ update_post_meta($new_post_id, self::QUOTED_META, (int) $quoted_post_id);
+ return true;
+ }
+
+ /**
+ * Get the quoted post ID for a beep.
+ */
+ public static function get_quoted_post_id($post_id) {
+ return (int) get_post_meta($post_id, self::QUOTED_META, true);
+ }
+
+ /**
+ * Get the quoted post data for rendering.
+ */
+ public static function get_quoted_post($post_id) {
+ $quoted_id = self::get_quoted_post_id($post_id);
+ if (!$quoted_id) return null;
+
+ $post = get_post($quoted_id);
+ if (!$post || $post->post_status !== 'publish') return null;
+
+ $author_id = $post->post_author;
+ $author = get_userdata($author_id);
+ $avatar = get_user_meta($author_id, 'beep_avatar', true);
+ if (!$avatar) {
+ $avatar = 'https://www.gravatar.com/avatar/' . md5(strtolower($author->user_email ?? '')) . '?s=48&d=mp';
+ }
+
+ $content = preg_replace('/\[beep:\d+\]/', '', $post->post_content);
+
+ return [
+ 'id' => $post->ID,
+ 'content' => $content,
+ 'time_ago'=> self::time_ago(strtotime($post->post_date)),
+ 'author' => [
+ 'id' => $author_id,
+ 'name' => $author->display_name ?? 'Unknown',
+ 'username' => $author->user_login ?? '',
+ 'avatar' => $avatar,
+ ],
+ ];
+ }
+
+ /**
+ * Render the quoted beep HTML (the embedded original beep).
+ */
+ public static function render_quoted_post($post_id) {
+ $quoted = self::get_quoted_post($post_id);
+ if (!$quoted) return '';
+
+ $author = $quoted['author'];
+ $avatar = esc_url($author['avatar']);
+ $name = esc_html($author['name']);
+ $handle = esc_html('@' . $author['username']);
+ $content = make_clickable(wp_kses_post($quoted['content']));
+ $time = esc_html($quoted['time_ago']);
+ $qid = esc_attr($quoted['id']);
+
+ return '' .
+ '
' .
+ '

' .
+ '
' .
+ '
' .
+ '
' .
+ '' . $name . '' .
+ '' . $handle . '' .
+ '·' .
+ '' . $time . '' .
+ '
' .
+ '
' . $content . '
' .
+ '
';
+ }
+
+ private static function time_ago($timestamp) {
+ $diff = time() - $timestamp;
+ if ($diff < 0) $diff = 0;
+ if ($diff < 60) return $diff . 's';
+ if ($diff < 3600) return floor($diff / 60) . 'm';
+ if ($diff < 86400) return floor($diff / 3600) . 'h';
+ if ($diff < 604800) return floor($diff / 86400) . 'd';
+ return gmdate('M j', $timestamp);
+ }
+}
diff --git a/includes/class-replies.php b/includes/class-replies.php
new file mode 100755
index 0000000..90116cf
--- /dev/null
+++ b/includes/class-replies.php
@@ -0,0 +1,252 @@
+ 'POST',
+ 'callback' => [__CLASS__, 'submit_beep'],
+ 'permission_callback' => ['Beep_Likes', 'can_interact'],
+ ]);
+ register_rest_route('beep/v1', '/reply/(?P\d+)', [
+ 'methods' => 'POST',
+ 'callback' => [__CLASS__, 'submit_reply'],
+ 'permission_callback' => ['Beep_Likes', 'can_interact'],
+ ]);
+ register_rest_route('beep/v1', '/thread/(?P\d+)', [
+ 'methods' => 'GET',
+ 'callback' => [__CLASS__, 'get_thread_api'],
+ 'permission_callback' => '__return_true',
+ ]);
+ register_rest_route('beep/v1', '/delete/(?P\d+)', [
+ 'methods' => 'DELETE',
+ 'callback' => [__CLASS__, 'delete_beep'],
+ 'permission_callback' => ['Beep_Likes', 'can_interact'],
+ ]);
+ register_rest_route('beep/v1', '/delete-reply/(?P\d+)', [
+ 'methods' => 'DELETE',
+ 'callback' => [__CLASS__, 'delete_reply'],
+ 'permission_callback' => ['Beep_Likes', 'can_interact'],
+ ]);
+ }
+
+ public static function submit_beep($request) {
+ $content = sanitize_textarea_field($request->get_param('content'));
+ if (empty($content)) {
+ return new WP_Error('empty_content', 'Beep cannot be empty.', ['status' => 400]);
+ }
+ if (mb_strlen($content) > BEEP_CHAR_LIMIT) {
+ return new WP_Error('content_too_long', 'Beep exceeds ' . BEEP_CHAR_LIMIT . ' character limit.', ['status' => 400]);
+ }
+
+ // Handle quote post ID
+ $quote_id = $request->get_param('quote_id');
+ $gif_url = $request->get_param('gif_url');
+
+ $post_id = wp_insert_post([
+ 'post_type' => 'beep',
+ 'post_content' => $content,
+ 'post_status' => 'publish',
+ 'post_author' => get_current_user_id(),
+ ]);
+ if (is_wp_error($post_id)) { return $post_id; }
+
+ // Attach quote if provided
+ if ($quote_id && is_numeric($quote_id)) {
+ Beep_Quotes::create_quote($post_id, (int) $quote_id);
+ }
+
+ // Attach GIF URL if provided
+ if ($gif_url && filter_var($gif_url, FILTER_VALIDATE_URL)) {
+ update_post_meta($post_id, 'beep_gif_url', esc_url_raw($gif_url));
+ }
+
+ return rest_ensure_response(['id' => $post_id, 'url' => get_permalink($post_id)]);
+ }
+
+ public static function submit_reply($request) {
+ $post_id = (int) $request['id'];
+ $parent = get_post($post_id);
+ if (!$parent || $parent->post_type !== 'beep') {
+ return new WP_Error('invalid_post', 'Invalid beep post.', ['status' => 400]);
+ }
+ $content = sanitize_textarea_field($request->get_param('content'));
+ if (empty($content)) {
+ return new WP_Error('empty_content', 'Reply cannot be empty.', ['status' => 400]);
+ }
+ $comment_id = wp_insert_comment([
+ 'comment_post_ID' => $post_id,
+ 'comment_content' => $content,
+ 'comment_author' => get_current_user_id() ? null : 'Anonymous',
+ 'comment_type' => 'comment',
+ 'user_id' => get_current_user_id(),
+ ]);
+ if (!$comment_id) {
+ return new WP_Error('insert_failed', 'Could not save reply.', ['status' => 500]);
+ }
+ return rest_ensure_response(['id' => $comment_id]);
+ }
+
+ public static function delete_beep($request) {
+ $post_id = (int) $request['id'];
+ $post = get_post($post_id);
+ if (!$post || $post->post_type !== 'beep') {
+ return new WP_Error('invalid_post', 'Invalid beep.', ['status' => 400]);
+ }
+ if (!current_user_can('delete_post', $post_id)) {
+ return new WP_Error('forbidden', 'You do not have permission to delete this beep.', ['status' => 403]);
+ }
+ // Clean up media
+ Beep_Media::delete_media($post_id);
+ $deleted = wp_delete_post($post_id, true);
+ if (!$deleted) {
+ return new WP_Error('delete_failed', 'Could not delete.', ['status' => 500]);
+ }
+ return rest_ensure_response(['deleted' => true]);
+ }
+
+ public static function delete_reply($request) {
+ $comment_id = (int) $request['id'];
+ $comment = get_comment($comment_id);
+ if (!$comment) {
+ return new WP_Error('invalid_reply', 'Reply not found.', ['status' => 404]);
+ }
+ // Only allow the reply author or a moderator to delete
+ $can_delete = (
+ (int) $comment->user_id === get_current_user_id() ||
+ current_user_can('moderate_comments')
+ );
+ if (!$can_delete) {
+ return new WP_Error('forbidden', 'You do not have permission to delete this reply.', ['status' => 403]);
+ }
+ $deleted = wp_delete_comment($comment_id, true);
+ if (!$deleted) {
+ return new WP_Error('delete_failed', 'Could not delete.', ['status' => 500]);
+ }
+ return rest_ensure_response(['deleted' => true]);
+ }
+
+ public static function get_replies($post_id) {
+ return get_comments([
+ 'post_id' => $post_id,
+ 'status' => 'approve',
+ 'order' => 'ASC',
+ 'type' => 'comment',
+ ]);
+ }
+
+ public static function reply_count($post_id) {
+ return (int) get_comments_number($post_id);
+ }
+
+ public static function get_thread($post_id, $depth = 0, $max_depth = 10) {
+ if ($depth > $max_depth) return [];
+ $post = get_post($post_id);
+ if (!$post || $post->post_type !== 'beep') return [];
+ $thread = [self::format_beep_reply($post, $depth)];
+ $replies = get_comments([
+ 'post_id' => $post_id, 'status' => 'approve', 'order' => 'ASC',
+ 'type' => 'comment', 'number' => 100,
+ ]);
+ foreach ($replies as $reply) {
+ if ($depth < $max_depth) {
+ $thread = array_merge($thread, self::get_comment_thread($reply->comment_ID, $depth + 1, $max_depth));
+ }
+ }
+ return $thread;
+ }
+
+ private static function get_comment_thread($comment_id, $depth, $max_depth) {
+ if ($depth > $max_depth) return [];
+ $comment = get_comment($comment_id);
+ if (!$comment) return [];
+ $result = [self::format_comment_reply($comment, $depth)];
+ $children = get_comments([
+ 'parent' => $comment_id, 'status' => 'approve', 'order' => 'ASC',
+ 'type' => 'comment', 'number' => 100,
+ ]);
+ foreach ($children as $child) {
+ $result = array_merge($result, self::get_comment_thread($child->comment_ID, $depth + 1, $max_depth));
+ }
+ return $result;
+ }
+
+ public static function get_thread_api($request) {
+ $post_id = (int) $request['post_id'];
+ $thread = self::get_thread($post_id);
+ return rest_ensure_response(['thread' => $thread]);
+ }
+
+ public static function format_beep_reply($post, $depth = 0) {
+ $author_id = $post->post_author;
+ $author = get_userdata($author_id);
+ $avatar = get_user_meta($author_id, 'beep_avatar', true);
+ if (!$avatar) {
+ $avatar = 'https://www.gravatar.com/avatar/' . md5(strtolower($author->user_email ?? '')) . '?s=48&d=mp';
+ }
+ return [
+ 'id' => $post->ID,
+ 'parent_id' => $post->post_parent ?: null,
+ 'is_post' => true,
+ 'depth' => $depth,
+ 'content' => $post->post_content,
+ 'time_ago' => self::time_ago(strtotime($post->post_date)),
+ 'like_count' => Beep_Likes::get_count($post->ID, 'post'),
+ 'reply_count' => self::reply_count($post->ID),
+ 'author' => [
+ 'id' => $author_id,
+ 'name' => $author->display_name ?? 'Unknown',
+ 'username' => $author->user_login ?? '',
+ 'avatar' => $avatar,
+ ],
+ ];
+ }
+
+ public static function format_comment_reply($comment, $depth = 0) {
+ $author_id = (int) $comment->user_id;
+ $author = $author_id ? get_userdata($author_id) : null;
+ if ($author_id) {
+ $avatar = get_user_meta($author_id, 'beep_avatar', true);
+ if (!$avatar) {
+ $avatar = 'https://www.gravatar.com/avatar/' . md5(strtolower($author->user_email ?? '')) . '?s=48&d=mp';
+ }
+ $name = $author->display_name ?? 'Unknown';
+ $username = $author->user_login ?? '';
+ } else {
+ $avatar = 'https://www.gravatar.com/avatar/?s=48&d=mp';
+ $name = esc_html($comment->comment_author);
+ $username = '';
+ }
+ return [
+ 'id' => $comment->comment_ID,
+ 'parent_id' => (int) $comment->comment_parent ?: null,
+ 'is_post' => false,
+ 'depth' => $depth,
+ 'content' => $comment->comment_content,
+ 'time_ago' => self::time_ago(strtotime($comment->comment_date)),
+ 'like_count' => Beep_Likes::get_count($comment->comment_ID, 'reply'),
+ 'reply_count' => 0,
+ 'author' => [
+ 'id' => $author_id,
+ 'name' => $name,
+ 'username' => $username,
+ 'avatar' => $avatar,
+ ],
+ ];
+ }
+
+ public static function time_ago($timestamp) {
+ $diff = time() - $timestamp;
+ if ($diff < 0) $diff = 0;
+ if ($diff < 60) return $diff . 's';
+ if ($diff < 3600) return floor($diff / 60) . 'm';
+ if ($diff < 86400) return floor($diff / 3600) . 'h';
+ if ($diff < 604800) return floor($diff / 86400) . 'd';
+ return gmdate('M j', $timestamp);
+ }
+}
diff --git a/includes/class-settings.php b/includes/class-settings.php
new file mode 100755
index 0000000..5370994
--- /dev/null
+++ b/includes/class-settings.php
@@ -0,0 +1,1118 @@
+ '#FFFFFF', // Twitter Light: white
+ 'bg_card' => '#F5F8FA', // Twitter Light: card background
+ 'bg_card_hover' => '#EBEEF0', // Twitter Light: hover state
+ 'bg_input' => '#F5F8FA', // Twitter Light: input fields
+
+ // Colors - Text
+ 'text_primary' => '#0F1419', // Twitter Light: main text (near black)
+ 'text_secondary' => '#536471', // Twitter Light: muted text
+ 'text_link' => '#1D9BF0', // Twitter Light: links (Twitter blue)
+
+ // Colors - Borders & Dividers
+ 'border_color' => '#EFF3F4', // Twitter Light: borders and dividers
+ 'border_hover' => '#CBD0D4', // Twitter Light: hover border
+
+ // Colors - Interactive
+ 'color_like' => '#F91880', // Like heart (Twitter pink)
+ 'color_repost' => '#00BA7C', // Repost green
+ 'color_reply' => '#1D9BF0', // Reply blue
+ 'color_quote' => '#1D9BF0', // Quote blue
+ 'color_poll' => '#1D9BF0', // Poll blue
+
+ // Colors - Buttons
+ 'btn_primary_bg' => '#1D9BF0', // Primary button background
+ 'btn_primary_text' => '#FFFFFF', // Primary button text
+ 'btn_secondary_bg' => '#2F3336', // Secondary button background
+ 'btn_secondary_text' => '#E7E9EA', // Secondary button text
+
+ // Colors - Avatar Ring
+ 'avatar_ring' => '#2F3336', // Avatar ring color
+
+ // Branding
+ 'logo_url' => '', // Custom logo URL
+ 'icon_url' => '', // Custom icon URL (favicon)
+ 'site_name' => 'Beep', // Or 'X' or 'Twitter'
+ 'tagline' => 'Messages from',
+
+ // Layout
+ 'feed_max_width' => '600px', // Max width of feed
+ 'feed_padding' => '16px', // Feed padding
+ 'post_padding' => '14px 20px', // Post padding
+ 'avatar_size' => '48px', // Avatar dimensions
+ 'gap_between_posts' => '12px', // Gap between posts
+
+ // Border Radius
+ 'radius_card' => '16px', // Card border radius
+ 'radius_avatar' => '9999px', // Avatar circle
+ 'radius_button' => '9999px', // Buttons/pills
+ 'radius_input' => '4px', // Input fields
+
+ // Typography
+ 'font_family' => '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
+ 'font_size_base' => '15px', // Base font size
+ 'font_size_small' => '13px', // Small text (timestamps)
+ 'font_size_large' => '20px', // Large text (names)
+
+ // Features (on/off)
+ 'enable_likes' => '1',
+ 'enable_reposts' => '1',
+ 'enable_quotes' => '1',
+ 'enable_polls' => '1',
+ 'enable_follows' => '1',
+ 'enable_media' => '1',
+ 'enable_replies' => '1',
+
+ // Behavior
+ 'character_limit' => '280',
+ 'posts_per_page' => '20',
+ 'auto_refresh' => '0', // Auto refresh interval (0 = off)
+ 'public_posting' => '0', // Allow public posting without login
+
+ // Content
+ 'show_avatar' => '1',
+ 'show_stats' => '1', // Show reply/like counts
+ 'show_timestamps' => '1', // Show relative time
+ 'show_gravatar' => '1', // Show Gravatar
+ 'show_public_notice' => '1', // Show "sign in to post" notice
+
+ // Animation
+ 'animation_speed' => '150', // Transition speed in ms
+ ];
+
+ /**
+ * Initialize settings
+ */
+ public static function init() {
+ add_action('admin_init', [__CLASS__, 'register_settings']);
+ add_action('admin_menu', [__CLASS__, 'add_settings_page']);
+ add_filter('plugin_action_links_' . plugin_basename(BEEP_FILE), [__CLASS__, 'add_settings_link']);
+ add_action('wp_head', [__CLASS__, 'inject_css_variables'], 100);
+ }
+
+ /**
+ * Get all settings with defaults
+ */
+ public static function get_settings() {
+ $saved = get_option(self::OPTION_KEY, []);
+ return wp_parse_args($saved, self::$defaults);
+ }
+
+ /**
+ * Get a single setting
+ */
+ public static function get($key, $default = null) {
+ $settings = self::get_settings();
+ return isset($settings[$key]) ? $settings[$key] : ($default ?? (self::$defaults[$key] ?? null));
+ }
+
+ /**
+ * Register settings with WordPress
+ */
+ public static function register_settings() {
+ register_setting(
+ self::OPTION_KEY,
+ self::OPTION_KEY,
+ [
+ 'type' => 'array',
+ 'sanitize_callback' => [__CLASS__, 'sanitize_settings'],
+ 'default' => [],
+ ]
+ );
+
+ // === Section: Colors - Backgrounds ===
+ add_settings_section(
+ 'beep_colors_bg',
+ 'Colors - Backgrounds',
+ [__CLASS__, 'section_colors_bg_description'],
+ 'beep-settings'
+ );
+
+ add_settings_field(
+ 'bg_main',
+ 'Main Background',
+ [__CLASS__, 'field_color_picker'],
+ 'beep-settings',
+ 'beep_colors_bg',
+ ['key' => 'bg_main', 'label' => 'Feed/Page background']
+ );
+
+ add_settings_field(
+ 'bg_card',
+ 'Card Background',
+ [__CLASS__, 'field_color_picker'],
+ 'beep-settings',
+ 'beep_colors_bg',
+ ['key' => 'bg_card', 'label' => 'Post cards & containers']
+ );
+
+ add_settings_field(
+ 'bg_card_hover',
+ 'Card Hover Background',
+ [__CLASS__, 'field_color_picker'],
+ 'beep-settings',
+ 'beep_colors_bg',
+ ['key' => 'bg_card_hover', 'label' => 'Hover state for cards']
+ );
+
+ add_settings_field(
+ 'bg_input',
+ 'Input Background',
+ [__CLASS__, 'field_color_picker'],
+ 'beep-settings',
+ 'beep_colors_bg',
+ ['key' => 'bg_input', 'label' => 'Textarea/input fields']
+ );
+
+ // === Section: Colors - Text ===
+ add_settings_section(
+ 'beep_colors_text',
+ 'Colors - Text',
+ [__CLASS__, 'section_colors_text_description'],
+ 'beep-settings'
+ );
+
+ add_settings_field(
+ 'text_primary',
+ 'Primary Text',
+ [__CLASS__, 'field_color_picker'],
+ 'beep-settings',
+ 'beep_colors_text',
+ ['key' => 'text_primary', 'label' => 'Main text color']
+ );
+
+ add_settings_field(
+ 'text_secondary',
+ 'Secondary Text',
+ [__CLASS__, 'field_color_picker'],
+ 'beep-settings',
+ 'beep_colors_text',
+ ['key' => 'text_secondary', 'label' => 'Muted/timestamp text']
+ );
+
+ add_settings_field(
+ 'text_link',
+ 'Link Color',
+ [__CLASS__, 'field_color_picker'],
+ 'beep-settings',
+ 'beep_colors_text',
+ ['key' => 'text_link', 'label' => 'Links & buttons']
+ );
+
+ // === Section: Colors - Interactive ===
+ add_settings_section(
+ 'beep_colors_interactive',
+ 'Colors - Interactive Elements',
+ [__CLASS__, 'section_colors_interactive_description'],
+ 'beep-settings'
+ );
+
+ add_settings_field(
+ 'color_like',
+ 'Like Heart Color',
+ [__CLASS__, 'field_color_picker'],
+ 'beep-settings',
+ 'beep_colors_interactive',
+ ['key' => 'color_like', 'label' => 'Heart/like button']
+ );
+
+ add_settings_field(
+ 'color_repost',
+ 'Repost Color',
+ [__CLASS__, 'field_color_picker'],
+ 'beep-settings',
+ 'beep_colors_interactive',
+ ['key' => 'color_repost', 'label' => 'Repost button']
+ );
+
+ add_settings_field(
+ 'color_reply',
+ 'Reply Color',
+ [__CLASS__, 'field_color_picker'],
+ 'beep-settings',
+ 'beep_colors_interactive',
+ ['key' => 'color_reply', 'label' => 'Reply button']
+ );
+
+ add_settings_field(
+ 'border_color',
+ 'Border Color',
+ [__CLASS__, 'field_color_picker'],
+ 'beep-settings',
+ 'beep_colors_interactive',
+ ['key' => 'border_color', 'label' => 'Dividers & borders']
+ );
+
+ // === Section: Layout ===
+ add_settings_section(
+ 'beep_layout',
+ 'Layout & Spacing',
+ [__CLASS__, 'section_layout_description'],
+ 'beep-settings'
+ );
+
+ add_settings_field(
+ 'feed_max_width',
+ 'Feed Max Width',
+ [__CLASS__, 'field_text'],
+ 'beep-settings',
+ 'beep_layout',
+ ['key' => 'feed_max_width', 'label' => 'e.g. 600px or 100%']
+ );
+
+ add_settings_field(
+ 'feed_padding',
+ 'Feed Padding',
+ [__CLASS__, 'field_text'],
+ 'beep-settings',
+ 'beep_layout',
+ ['key' => 'feed_padding', 'label' => 'e.g. 16px']
+ );
+
+ add_settings_field(
+ 'post_padding',
+ 'Post Padding',
+ [__CLASS__, 'field_text'],
+ 'beep-settings',
+ 'beep_layout',
+ ['key' => 'post_padding', 'label' => 'e.g. 14px 20px']
+ );
+
+ add_settings_field(
+ 'avatar_size',
+ 'Avatar Size',
+ [__CLASS__, 'field_text'],
+ 'beep-settings',
+ 'beep_layout',
+ ['key' => 'avatar_size', 'label' => 'e.g. 48px']
+ );
+
+ // === Section: Border Radius ===
+ add_settings_section(
+ 'beep_radius',
+ 'Border Radius',
+ [__CLASS__, 'section_radius_description'],
+ 'beep-settings'
+ );
+
+ add_settings_field(
+ 'radius_card',
+ 'Card Radius',
+ [__CLASS__, 'field_text'],
+ 'beep-settings',
+ 'beep_radius',
+ ['key' => 'radius_card', 'label' => 'e.g. 16px for cards, 0 for sharp edges']
+ );
+
+ add_settings_field(
+ 'radius_avatar',
+ 'Avatar Radius',
+ [__CLASS__, 'field_text'],
+ 'beep-settings',
+ 'beep_radius',
+ ['key' => 'radius_avatar', 'label' => 'e.g. 9999px for circle']
+ );
+
+ // === Section: Typography ===
+ add_settings_section(
+ 'beep_typography',
+ 'Typography',
+ [__CLASS__, 'section_typography_description'],
+ 'beep-settings'
+ );
+
+ add_settings_field(
+ 'font_family',
+ 'Font Family',
+ [__CLASS__, 'field_text'],
+ 'beep-settings',
+ 'beep_typography',
+ ['key' => 'font_family', 'label' => 'CSS font-family value']
+ );
+
+ add_settings_field(
+ 'font_size_base',
+ 'Base Font Size',
+ [__CLASS__, 'field_text'],
+ 'beep-settings',
+ 'beep_typography',
+ ['key' => 'font_size_base', 'label' => 'e.g. 15px']
+ );
+
+ // === Section: Branding & Media ===
+ add_settings_section(
+ 'beep_branding',
+ 'Branding & Media',
+ [__CLASS__, 'section_branding_description'],
+ 'beep-settings'
+ );
+
+ add_settings_field(
+ 'logo_url',
+ 'Logo Image',
+ [__CLASS__, 'field_media_upload'],
+ 'beep-settings',
+ 'beep_branding',
+ ['key' => 'logo_url', 'label' => 'Upload a logo image. Leave empty to use text.']
+ );
+
+ add_settings_field(
+ 'icon_url',
+ 'Icon Image',
+ [__CLASS__, 'field_media_upload'],
+ 'beep-settings',
+ 'beep_branding',
+ ['key' => 'icon_url', 'label' => 'Upload an icon/favicon image.']
+ );
+
+ add_settings_field(
+ 'site_name',
+ 'Site Name',
+ [__CLASS__, 'field_text'],
+ 'beep-settings',
+ 'beep_branding',
+ ['key' => 'site_name', 'label' => 'e.g. Beep, X, or Twitter']
+ );
+
+ add_settings_field(
+ 'tagline',
+ 'Tagline',
+ [__CLASS__, 'field_text'],
+ 'beep-settings',
+ 'beep_branding',
+ ['key' => 'tagline', 'label' => 'Text above logo, e.g. "Messages from"']
+ );
+
+ // === Section: Features ===
+ add_settings_section(
+ 'beep_features',
+ 'Features',
+ [__CLASS__, 'section_features_description'],
+ 'beep-settings'
+ );
+
+ add_settings_field(
+ 'enable_likes',
+ 'Enable Likes',
+ [__CLASS__, 'field_checkbox'],
+ 'beep-settings',
+ 'beep_features',
+ ['key' => 'enable_likes', 'label' => 'Show like button on posts']
+ );
+
+ add_settings_field(
+ 'enable_reposts',
+ 'Enable Reposts',
+ [__CLASS__, 'field_checkbox'],
+ 'beep-settings',
+ 'beep_features',
+ ['key' => 'enable_reposts', 'label' => 'Show repost button']
+ );
+
+ add_settings_field(
+ 'enable_quotes',
+ 'Enable Quotes',
+ [__CLASS__, 'field_checkbox'],
+ 'beep-settings',
+ 'beep_features',
+ ['key' => 'enable_quotes', 'label' => 'Show quote button']
+ );
+
+ add_settings_field(
+ 'enable_replies',
+ 'Enable Replies',
+ [__CLASS__, 'field_checkbox'],
+ 'beep-settings',
+ 'beep_features',
+ ['key' => 'enable_replies', 'label' => 'Allow replying to posts']
+ );
+
+ add_settings_field(
+ 'enable_media',
+ 'Enable Media',
+ [__CLASS__, 'field_checkbox'],
+ 'beep-settings',
+ 'beep_features',
+ ['key' => 'enable_media', 'label' => 'Allow image attachments']
+ );
+
+ add_settings_field(
+ 'enable_polls',
+ 'Enable Polls',
+ [__CLASS__, 'field_checkbox'],
+ 'beep-settings',
+ 'beep_features',
+ ['key' => 'enable_polls', 'label' => 'Allow poll creation']
+ );
+
+ // === Section: Behavior ===
+ add_settings_section(
+ 'beep_behavior',
+ 'Behavior',
+ [__CLASS__, 'section_behavior_description'],
+ 'beep-settings'
+ );
+
+ add_settings_field(
+ 'character_limit',
+ 'Character Limit',
+ [__CLASS__, 'field_text'],
+ 'beep-settings',
+ 'beep_behavior',
+ ['key' => 'character_limit', 'label' => 'Max characters per post (default 280)']
+ );
+
+ add_settings_field(
+ 'posts_per_page',
+ 'Posts Per Page',
+ [__CLASS__, 'field_text'],
+ 'beep-settings',
+ 'beep_behavior',
+ ['key' => 'posts_per_page', 'label' => 'Number of posts to load']
+ );
+
+ add_settings_field(
+ 'public_posting',
+ 'Allow Public Posting',
+ [__CLASS__, 'field_checkbox'],
+ 'beep-settings',
+ 'beep_behavior',
+ ['key' => 'public_posting', 'label' => 'Allow posting without logging in']
+ );
+
+ // === Section: Content Display ===
+ add_settings_section(
+ 'beep_content',
+ 'Content Display',
+ [__CLASS__, 'section_content_description'],
+ 'beep-settings'
+ );
+
+ add_settings_field(
+ 'show_avatar',
+ 'Show Avatars',
+ [__CLASS__, 'field_checkbox'],
+ 'beep-settings',
+ 'beep_content',
+ ['key' => 'show_avatar', 'label' => 'Display user profile pictures']
+ );
+
+ add_settings_field(
+ 'show_stats',
+ 'Show Statistics',
+ [__CLASS__, 'field_checkbox'],
+ 'beep-settings',
+ 'beep_content',
+ ['key' => 'show_stats', 'label' => 'Display reply/like counts']
+ );
+
+ add_settings_field(
+ 'show_timestamps',
+ 'Show Timestamps',
+ [__CLASS__, 'field_checkbox'],
+ 'beep-settings',
+ 'beep_content',
+ ['key' => 'show_timestamps', 'label' => 'Display relative time']
+ );
+
+ add_settings_field(
+ 'show_public_notice',
+ 'Show Public Notice',
+ [__CLASS__, 'field_checkbox'],
+ 'beep-settings',
+ 'beep_content',
+ ['key' => 'show_public_notice', 'label' => 'Show "Sign in to post" notice']
+ );
+ }
+
+ /**
+ * Add settings page to admin menu
+ */
+ public static function add_settings_page() {
+ add_options_page(
+ 'Beep Settings',
+ 'Beep',
+ 'manage_options',
+ 'beep-settings',
+ [__CLASS__, 'render_settings_page']
+ );
+ }
+
+ /**
+ * Add settings link on Plugins page
+ */
+ public static function add_settings_link($links) {
+ $settings_link = '' . __('Settings') . '';
+ array_unshift($links, $settings_link);
+ return $links;
+ }
+
+ /**
+ * Sanitize settings on save
+ */
+ public static function sanitize_settings($input) {
+ $sanitized = [];
+
+ foreach (self::$defaults as $key => $default) {
+ if (is_bool($default)) {
+ $sanitized[$key] = !empty($input[$key]) ? '1' : '0';
+ } elseif (is_int($default)) {
+ $sanitized[$key] = isset($input[$key]) ? intval($input[$key]) : $default;
+ } else {
+ $sanitized[$key] = isset($input[$key]) ? sanitize_text_field($input[$key]) : $default;
+ }
+ }
+
+ return $sanitized;
+ }
+
+ /**
+ * Render the settings page
+ */
+ public static function render_settings_page() {
+ if (!current_user_can('manage_options')) {
+ return;
+ }
+ ?>
+
+
Beep Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ */
+ public static function inject_css_variables() {
+ $settings = self::get_settings();
+ ?>
+
+ Background colors for the feed and cards.';
+ }
+
+ public static function section_colors_text_description() {
+ echo 'Text colors throughout the feed.
';
+ }
+
+ public static function section_colors_interactive_description() {
+ echo 'Colors for interactive elements like buttons and icons.
';
+ }
+
+ public static function section_layout_description() {
+ echo 'Spacing and layout dimensions.
';
+ }
+
+ public static function section_radius_description() {
+ echo 'Border radius for rounded corners. Use 0px for sharp edges (Twitter style), 16px for rounded cards.
';
+ }
+
+ public static function section_typography_description() {
+ echo 'Font settings.
';
+ }
+
+ public static function section_branding_description() {
+ echo 'Logo, icon, and site name settings.
';
+ }
+
+ public static function section_features_description() {
+ echo 'Toggle features on or off.
';
+ }
+
+ public static function section_behavior_description() {
+ echo 'How the feed behaves.
';
+ }
+
+ public static function section_content_description() {
+ echo 'What to show/hide in the feed.
';
+ }
+
+ // === Field Renderers ===
+ public static function field_color_picker($args) {
+ $key = $args['key'];
+ $label = $args['label'];
+ $value = self::get($key);
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ Recommended for :
+
+
+
Transparent PNG/SVG — the header has a blurred backdrop
+
+
Will be displayed at 32×32px in posts, 24×24px in replies
+
+
+
+
+
+
+ 'GET',
+ 'callback' => [__CLASS__, 'fetch_feed'],
+ 'permission_callback' => '__return_true',
+ 'args' => [
+ 'sort' => ['default' => 'latest'],
+ 'limit' => ['default' => 50, 'validate_callback' => function ($v) { return is_numeric($v); }],
+ 'offset' => ['default' => 0, 'validate_callback' => function ($v) { return is_numeric($v); }],
+ ],
+ ]);
+ }
+
+ /**
+ * REST endpoint: re-render the feed list with the given sort.
+ */
+ public static function fetch_feed($request) {
+ $sort = $request->get_param('sort') === 'top' ? 'top' : 'latest';
+ $limit = max(1, (int) $request->get_param('limit'));
+ $offset = max(0, (int) $request->get_param('offset'));
+ $posts = ($sort === 'top') ? self::get_top_beeps($limit + 1, $offset) : self::get_latest_beeps($limit + 1, $offset);
+
+ $has_more = count($posts) > $limit;
+ if ($has_more) {
+ $posts = array_slice($posts, 0, $limit);
+ }
+
+ $html = '';
+ foreach ($posts as $p) {
+ $html .= beep_render_post($p);
+ }
+ if ($html === '' && $offset === 0) {
+ $empty_bird = beep_bird_svg( ['size' => 64, 'fill' => '#4a90c4'] );
+ $html = '' . $empty_bird . '
No beeps yet.
Be the first to share something! ';
+ }
+ return rest_ensure_response(['html' => $html, 'sort' => $sort, 'has_more' => $has_more, 'offset' => $offset + count($posts)]);
+ }
+
+ public static function get_latest_beeps($limit, $offset = 0) {
+ global $wpdb;
+ return $wpdb->get_results($wpdb->prepare(
+ "SELECT * FROM {$wpdb->posts}
+ WHERE post_type = %s AND post_status = 'publish'
+ ORDER BY post_date DESC
+ LIMIT %d OFFSET %d",
+ Beep_CPT::POST_TYPE, $limit, $offset
+ ));
+ }
+
+ public static function get_top_beeps($limit, $offset = 0) {
+ global $wpdb;
+ $likes = Beep_Likes::table_name();
+ return $wpdb->get_results($wpdb->prepare(
+ "SELECT p.*, COALESCE(l.cnt, 0) AS like_count
+ FROM {$wpdb->posts} p
+ LEFT JOIN (
+ SELECT post_id, COUNT(*) AS cnt
+ FROM $likes
+ WHERE object_type = 'post'
+ GROUP BY post_id
+ ) l ON l.post_id = p.ID
+ WHERE p.post_type = %s AND p.post_status = 'publish'
+ ORDER BY COALESCE(l.cnt, 0) DESC, p.post_date DESC
+ LIMIT %d OFFSET %d",
+ Beep_CPT::POST_TYPE, $limit, $offset
+ ));
+ }
+
+ public static function render_feed($atts) {
+ $atts = shortcode_atts([
+ 'limit' => 50,
+ 'header' => 'Beep!',
+ 'show_wordmark' => true,
+ 'sort' => 'latest',
+ ], $atts, 'beep_feed');
+
+ $limit = max(1, (int) $atts['limit']);
+ $sort = ($atts['sort'] === 'top') ? 'top' : 'latest';
+ $posts = ($sort === 'top') ? self::get_top_beeps($limit) : self::get_latest_beeps($limit);
+ $has_posts = !empty($posts);
+
+ ob_start();
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 64, 'fill' => '#4a90c4'] );
+ echo '
' . $empty_bird . '
No beeps yet.
Be the first to share something!';
+ }
+ ?>
+
+
+
+ \d+)', [
+ 'methods' => 'POST',
+ 'callback' => [__CLASS__, 'upload_media'],
+ 'permission_callback' => function () {
+ return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
+ },
+ ]);
+ register_rest_route('beep/v1', '/media/(?P\d+)/(?P\d+)', [
+ 'methods' => 'DELETE',
+ 'callback' => [__CLASS__, 'delete_media'],
+ 'permission_callback' => function () {
+ return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
+ },
+ ]);
+ register_rest_route('beep/v1', '/voice/(?P\d+)', [
+ 'methods' => 'POST',
+ 'callback' => [__CLASS__, 'upload_voice'],
+ 'permission_callback' => function () {
+ return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
+ },
+ ]);
+ }
+
+ public static function upload_media($request) {
+ $post_id = (int) $request->get_param('post_id');
+ $post = get_post($post_id);
+
+ if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
+ return new WP_Error('not_found', 'Beep not found.', 404);
+ }
+ if (!current_user_can('edit_post', $post_id)) {
+ return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
+ }
+ if (empty($_FILES['file'])) {
+ return new WP_Error('no_file', 'No file uploaded.');
+ }
+
+ $result = Beep_Media::handle_upload($post_id, $_FILES['file']);
+ if (is_wp_error($result)) {
+ return $result;
+ }
+ return rest_ensure_response($result);
+ }
+
+ public static function delete_media($request) {
+ $post_id = (int) $request->get_param('post_id');
+ $attachment_id = (int) $request->get_param('attachment_id');
+ $post = get_post($post_id);
+
+ if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
+ return new WP_Error('not_found', 'Beep not found.', 404);
+ }
+ if (!current_user_can('edit_post', $post_id)) {
+ return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
+ }
+
+ Beep_Media::remove_image($post_id, $attachment_id);
+ return rest_ensure_response(['ok' => true]);
+ }
+ public static function upload_voice($request) {
+ $post_id = (int) $request->get_param('post_id');
+ $post = get_post($post_id);
+
+ if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
+ return new WP_Error('not_found', 'Beep not found.', 404);
+ }
+ if (!current_user_can('edit_post', $post_id)) {
+ return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
+ }
+ if (empty($_FILES['file'])) {
+ return new WP_Error('no_file', 'No file uploaded.');
+ }
+
+ $result = Beep_Media::handle_voice_upload($post_id, $_FILES['file']);
+ if (is_wp_error($result)) {
+ return $result;
+ }
+ return rest_ensure_response($result);
+ }
+
+
+ public static function register_thread_routes() {
+ register_rest_route('beep/v1', '/thread/(?P\d+)', [
+ 'methods' => 'GET',
+ 'callback' => [__CLASS__, 'get_thread'],
+ 'permission_callback' => '__return_true',
+ ]);
+ }
+
+ public static function get_thread($request) {
+ $post_id = (int) $request->get_param('post_id');
+ if (get_post_type($post_id) !== Beep_CPT::POST_TYPE) {
+ return new WP_Error('not_found', 'Beep not found.', 404);
+ }
+ $depth = (int) $request->get_param('depth') ?: 10;
+ $thread = Beep_Replies::get_thread($post_id, 0, $depth);
+ return rest_ensure_response($thread);
+ }
+
+ public static function register_quote_routes() {
+ register_rest_route('beep/v1', '/quote/(?P\d+)', [
+ 'methods' => 'GET',
+ 'callback' => [__CLASS__, 'get_quoted_post'],
+ 'permission_callback' => '__return_true',
+ ]);
+ register_rest_route('beep/v1', '/lookup', [
+ 'methods' => 'GET',
+ 'callback' => [__CLASS__, 'lookup_beep'],
+ 'permission_callback' => '__return_true',
+ ]);
+ }
+
+ public static function get_quoted_post($request) {
+ $post_id = (int) $request->get_param('post_id');
+ $quoted = Beep_Quotes::get_quoted_post($post_id);
+ if (!$quoted) {
+ return new WP_Error('not_found', 'No quoted post found.', 404);
+ }
+ return rest_ensure_response($quoted);
+ }
+
+ public static function lookup_beep($request) {
+ $query = sanitize_text_field($request->get_param('q'));
+ if (!$query) {
+ return new WP_Error('missing_query', 'Query parameter q is required.', 400);
+ }
+ if (is_numeric($query)) {
+ $post = get_post((int) $query);
+ if ($post && $post->post_type === Beep_CPT::POST_TYPE) {
+ return rest_ensure_response(Beep_Quotes::get_quoted_post($post->ID));
+ }
+ }
+ if (preg_match('/\[beep:(\d+)\]/', $query, $m)) {
+ $post = get_post((int) $m[1]);
+ if ($post && $post->post_type === Beep_CPT::POST_TYPE) {
+ return rest_ensure_response(Beep_Quotes::get_quoted_post($post->ID));
+ }
+ }
+ return new WP_Error('not_found', 'Beep not found.', 404);
+ }
+}
diff --git a/includes/class-shortcode.php.bak b/includes/class-shortcode.php.bak
new file mode 100755
index 0000000..8fd52c4
--- /dev/null
+++ b/includes/class-shortcode.php.bak
@@ -0,0 +1,292 @@
+ 'GET',
+ 'callback' => [__CLASS__, 'fetch_feed'],
+ 'permission_callback' => '__return_true',
+ 'args' => [
+ 'sort' => ['default' => 'latest'],
+ 'limit' => ['default' => 50, 'validate_callback' => function ($v) { return is_numeric($v); }],
+ 'offset' => ['default' => 0, 'validate_callback' => function ($v) { return is_numeric($v); }],
+ ],
+ ]);
+ }
+
+ /**
+ * REST endpoint: re-render the feed list with the given sort.
+ */
+ public static function fetch_feed($request) {
+ $sort = $request->get_param('sort') === 'top' ? 'top' : 'latest';
+ $limit = max(1, (int) $request->get_param('limit'));
+ $offset = max(0, (int) $request->get_param('offset'));
+ $posts = ($sort === 'top') ? self::get_top_beeps($limit + 1, $offset) : self::get_latest_beeps($limit + 1, $offset);
+
+ $has_more = count($posts) > $limit;
+ if ($has_more) {
+ $posts = array_slice($posts, 0, $limit);
+ }
+
+ $html = '';
+ foreach ($posts as $p) {
+ $html .= beep_render_post($p);
+ }
+ if ($html === '' && $offset === 0) {
+ $empty_bird = beep_bird_svg( ['size' => 64, 'fill' => '#4a90c4'] );
+ $html = '' . $empty_bird . '
No beeps yet.
Be the first to share something! ';
+ }
+ return rest_ensure_response(['html' => $html, 'sort' => $sort, 'has_more' => $has_more, 'offset' => $offset + count($posts)]);
+ }
+
+ public static function get_latest_beeps($limit, $offset = 0) {
+ global $wpdb;
+ return $wpdb->get_results($wpdb->prepare(
+ "SELECT * FROM {$wpdb->posts}
+ WHERE post_type = %s AND post_status = 'publish'
+ ORDER BY post_date DESC
+ LIMIT %d OFFSET %d",
+ Beep_CPT::POST_TYPE, $limit, $offset
+ ));
+ }
+
+ public static function get_top_beeps($limit, $offset = 0) {
+ global $wpdb;
+ $likes = Beep_Likes::table_name();
+ return $wpdb->get_results($wpdb->prepare(
+ "SELECT p.*, COALESCE(l.cnt, 0) AS like_count
+ FROM {$wpdb->posts} p
+ LEFT JOIN (
+ SELECT post_id, COUNT(*) AS cnt
+ FROM $likes
+ WHERE object_type = 'post'
+ GROUP BY post_id
+ ) l ON l.post_id = p.ID
+ WHERE p.post_type = %s AND p.post_status = 'publish'
+ ORDER BY COALESCE(l.cnt, 0) DESC, p.post_date DESC
+ LIMIT %d OFFSET %d",
+ Beep_CPT::POST_TYPE, $limit, $offset
+ ));
+ }
+
+ public static function render_feed($atts) {
+ $atts = shortcode_atts([
+ 'limit' => 50,
+ 'header' => 'Beep!',
+ 'show_wordmark' => true,
+ 'sort' => 'latest',
+ ], $atts, 'beep_feed');
+
+ $limit = max(1, (int) $atts['limit']);
+ $sort = ($atts['sort'] === 'top') ? 'top' : 'latest';
+ $posts = ($sort === 'top') ? self::get_top_beeps($limit) : self::get_latest_beeps($limit);
+ $has_posts = !empty($posts);
+
+ ob_start();
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your account has been suspended from beeping.
+
+
+
+
+
+
+ 64, 'fill' => '#4a90c4'] );
+ echo '
' . $empty_bird . '
No beeps yet.
Be the first to share something!';
+ }
+ ?>
+
+
+
+ \d+)', [
+ 'methods' => 'POST',
+ 'callback' => [__CLASS__, 'upload_media'],
+ 'permission_callback' => function () {
+ return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
+ },
+ ]);
+ register_rest_route('beep/v1', '/media/(?P\d+)/(?P\d+)', [
+ 'methods' => 'DELETE',
+ 'callback' => [__CLASS__, 'delete_media'],
+ 'permission_callback' => function () {
+ return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
+ },
+ ]);
+ register_rest_route('beep/v1', '/voice/(?P\d+)', [
+ 'methods' => 'POST',
+ 'callback' => [__CLASS__, 'upload_voice'],
+ 'permission_callback' => function () {
+ return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
+ },
+ ]);
+ }
+
+ public static function upload_media($request) {
+ $post_id = (int) $request->get_param('post_id');
+ $post = get_post($post_id);
+
+ if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
+ return new WP_Error('not_found', 'Beep not found.', 404);
+ }
+ if (!current_user_can('edit_post', $post_id)) {
+ return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
+ }
+ if (empty($_FILES['file'])) {
+ return new WP_Error('no_file', 'No file uploaded.');
+ }
+
+ $result = Beep_Media::handle_upload($post_id, $_FILES['file']);
+ if (is_wp_error($result)) {
+ return $result;
+ }
+ return rest_ensure_response($result);
+ }
+
+ public static function delete_media($request) {
+ $post_id = (int) $request->get_param('post_id');
+ $attachment_id = (int) $request->get_param('attachment_id');
+ $post = get_post($post_id);
+
+ if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
+ return new WP_Error('not_found', 'Beep not found.', 404);
+ }
+ if (!current_user_can('edit_post', $post_id)) {
+ return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
+ }
+
+ Beep_Media::remove_image($post_id, $attachment_id);
+ return rest_ensure_response(['ok' => true]);
+ }
+ public static function upload_voice($request) {
+ $post_id = (int) $request->get_param('post_id');
+ $post = get_post($post_id);
+
+ if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
+ return new WP_Error('not_found', 'Beep not found.', 404);
+ }
+ if (!current_user_can('edit_post', $post_id)) {
+ return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
+ }
+ if (empty($_FILES['file'])) {
+ return new WP_Error('no_file', 'No file uploaded.');
+ }
+
+ $result = Beep_Media::handle_voice_upload($post_id, $_FILES['file']);
+ if (is_wp_error($result)) {
+ return $result;
+ }
+ return rest_ensure_response($result);
+ }
+
+
+ public static function register_thread_routes() {
+ register_rest_route('beep/v1', '/thread/(?P\d+)', [
+ 'methods' => 'GET',
+ 'callback' => [__CLASS__, 'get_thread'],
+ 'permission_callback' => '__return_true',
+ ]);
+ }
+
+ public static function get_thread($request) {
+ $post_id = (int) $request->get_param('post_id');
+ if (get_post_type($post_id) !== Beep_CPT::POST_TYPE) {
+ return new WP_Error('not_found', 'Beep not found.', 404);
+ }
+ $depth = (int) $request->get_param('depth') ?: 10;
+ $thread = Beep_Replies::get_thread($post_id, 0, $depth);
+ return rest_ensure_response($thread);
+ }
+
+ public static function register_quote_routes() {
+ register_rest_route('beep/v1', '/quote/(?P\d+)', [
+ 'methods' => 'GET',
+ 'callback' => [__CLASS__, 'get_quoted_post'],
+ 'permission_callback' => '__return_true',
+ ]);
+ register_rest_route('beep/v1', '/lookup', [
+ 'methods' => 'GET',
+ 'callback' => [__CLASS__, 'lookup_beep'],
+ 'permission_callback' => '__return_true',
+ ]);
+ }
+
+ public static function get_quoted_post($request) {
+ $post_id = (int) $request->get_param('post_id');
+ $quoted = Beep_Quotes::get_quoted_post($post_id);
+ if (!$quoted) {
+ return new WP_Error('not_found', 'No quoted post found.', 404);
+ }
+ return rest_ensure_response($quoted);
+ }
+
+ public static function lookup_beep($request) {
+ $query = sanitize_text_field($request->get_param('q'));
+ if (!$query) {
+ return new WP_Error('missing_query', 'Query parameter q is required.', 400);
+ }
+ if (is_numeric($query)) {
+ $post = get_post((int) $query);
+ if ($post && $post->post_type === Beep_CPT::POST_TYPE) {
+ return rest_ensure_response(Beep_Quotes::get_quoted_post($post->ID));
+ }
+ }
+ if (preg_match('/\[beep:(\d+)\]/', $query, $m)) {
+ $post = get_post((int) $m[1]);
+ if ($post && $post->post_type === Beep_CPT::POST_TYPE) {
+ return rest_ensure_response(Beep_Quotes::get_quoted_post($post->ID));
+ }
+ }
+ return new WP_Error('not_found', 'Beep not found.', 404);
+ }
+}
diff --git a/includes/helpers.php b/includes/helpers.php
new file mode 100755
index 0000000..82241e2
--- /dev/null
+++ b/includes/helpers.php
@@ -0,0 +1,589 @@
+'
+ . ''
+ . '
'
+ . ''
+ . '';
+}
+
+/**
+ * Wordmark (bird + "Beep!") shown at the top of the feed.
+ * Uses the real Sami-designed bird PNG with CSS color filter
+ * to match brand colours.
+ */
+function beep_logo_svg() {
+ // Allow site-owners to swap in a custom logo via settings.
+ $custom = Beep_Settings::get( 'logo_url' );
+ if ( $custom ) {
+ return '
';
+ }
+
+ // Real bird PNG as
— CSS filter turns black silhouette → brand blue.
+ $filter = 'brightness(0) saturate(100%) invert(30%) sepia(100%) saturate(10000%) hue-rotate(190deg)';
+ return '
';
+}
+
+/**
+ * Is this user banned from posting/liking/replying?
+ */
+function beep_user_is_banned($user_id) {
+ if (!$user_id) {
+ return false;
+ }
+ return get_user_meta($user_id, 'beep_banned', true) === '1';
+}
+
+/**
+ * Render the composer textarea (for logged-in, non-banned users).
+ */
+function beep_render_composer() {
+ $user = wp_get_current_user();
+ $beep_avatar = get_user_meta($post->post_author, 'beep_avatar', true);
+ if ($beep_avatar) {
+ $avatar_url = $beep_avatar;
+ $filter = '';
+ } elseif ($author && !empty($author->user_email)) {
+ $avatar_url = 'https://www.gravatar.com/avatar/' . md5(strtolower($author->user_email)) . '?s=96&d=mp';
+ $filter = '';
+ } else {
+ $avatar_url = BEEP_URL . 'assets/beep-bird.png';
+ $filter = 'brightness(0) saturate(100%) invert(22%) sepia(100%) saturate(10000%) hue-rotate(190deg)';
+ }
+ $avatar = '
';
+
+ ob_start();
+ ?>
+
+ post_author);
+ $beep_avatar = get_user_meta($post->post_author, 'beep_avatar', true);
+ if ($beep_avatar) {
+ $avatar_url = $beep_avatar;
+ $filter = '';
+ } elseif ($author && !empty($author->user_email)) {
+ $avatar_url = 'https://www.gravatar.com/avatar/' . md5(strtolower($author->user_email)) . '?s=96&d=mp';
+ $filter = '';
+ } else {
+ $avatar_url = BEEP_URL . 'assets/beep-bird.png';
+ $filter = 'brightness(0) saturate(100%) invert(22%) sepia(100%) saturate(10000%) hue-rotate(190deg)';
+ }
+ $avatar = '
';
+ $name = $author ? esc_html($author->display_name) : 'Unknown';
+ $handle = $author ? '@' . esc_html($author->user_login) : '';
+ $ts = strtotime($post->post_date_gmt . ' UTC');
+ $time = beep_relative_time($ts);
+ $time_iso = gmdate('c', $ts);
+ $time_full = mysql2date('F j, Y g:i a', $post->post_date);
+
+ $content = beep_format_content($post->post_content);
+ $like_count = Beep_Likes::get_count($post->ID, 'post');
+ $reply_count = Beep_Replies::reply_count($post->ID);
+ $user_liked = Beep_Likes::user_has_liked($post->ID, 'post');
+ $can_delete = current_user_can('delete_post', $post->ID);
+ $can_interact = is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
+ $beep_images = Beep_Media::get_images($post->ID);
+ $beep_video = Beep_Media::get_video_url($post->ID);
+ $beep_voice = Beep_Media::get_voice_url($post->ID);
+
+ ob_start();
+ ?>
+
+
+
+
+ 18, 'fill' => '#0064cd', 'class' => 'beep-post-badge'] ); ?>
+
+
+
+
+ ·
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ID); ?>
+
+
+ ID);
+ ?>
+
+
+ ID); ?>
+
+
+ ID, 'beep_gif_url', true);
+ ?>
+
+
+
+ ID);
+ if ($poll) : ?>
+
+
+
+
+ votes
+
+
+ Final results
+
+ · Ends
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Beep!
+
+
+
+ ID, 0); ?>
+
+
+
+ ID);
+ echo beep_render_replies_tree($all, 0, 0, $post->ID, $can_interact);
+ ?>
+
+
Beep!
+
+
+
+
+
+
+
+ comment_parent !== (int) $parent_id) {
+ continue;
+ }
+ $out .= beep_render_reply($c, $comments, $depth, $post_id, $can_interact);
+ }
+ return $out;
+}
+
+/**
+ * Render a single reply, plus its nested children.
+ */
+function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id = 0, $can_interact = false) {
+ if (!$comment) {
+ return '';
+ }
+
+ $author_id = (int) $comment->user_id;
+ $avatar_url = BEEP_URL . 'assets/beep-bird.png';
+ $filter = 'brightness(0) saturate(100%) invert(22%) sepia(100%) saturate(10000%) hue-rotate(190deg)';
+ $avatar = '
';
+
+ $author = $author_id ? get_userdata($author_id) : null;
+ $name = $author ? esc_html($author->display_name) : esc_html($comment->comment_author);
+ $handle = $author ? '@' . esc_html($author->user_login) : '';
+
+ $ts = strtotime($comment->comment_date_gmt . ' UTC');
+ $time = beep_relative_time($ts);
+ $time_iso = gmdate('c', $ts);
+
+ $content = beep_format_content($comment->comment_content);
+ $can_delete = current_user_can('moderate_comments');
+ $like_count = Beep_Likes::get_count($comment->comment_ID, 'reply');
+ $user_liked = Beep_Likes::user_has_liked($comment->comment_ID, 'reply');
+
+ $depth_class = 'beep-depth-' . min((int) $depth, 2);
+
+ ob_start();
+ ?>
+
+
+
+
+ 16, 'fill' => '#0064cd', 'class' => 'beep-post-badge beep-post-badge-sm'] ); ?>
+
+
+
+
+ ·
+
+
+ 16, 'fill' => '#0064cd', 'class' => 'beep-post-badge beep-post-badge-sm'] ); ?>
+
+
+
+
+
+
+
+
+ comment_ID); ?>
+
+
+
+
+
+
+
+ comment_ID, $depth + 1, $post_id, $can_interact);
+ if ($children) {
+ echo '
' . $children . '
';
+ }
+ ?>
+
+
+ #' . esc_html($m[2]) . '';
+ }, $text);
+ // @mentions
+ $text = preg_replace_callback('/(^|\s)@([a-zA-Z0-9_]+)/', function ($m) {
+ $user = get_user_by('login', $m[2]);
+ if ($user) {
+ return $m[1] . '@' . esc_html($m[2]) . '';
+ }
+ return $m[1] . '@' . esc_html($m[2]) . '';
+ }, $text);
+ // URLs
+ $text = preg_replace_callback('#(https?://[^\s<]+)#i', function ($m) {
+ $url = esc_url($m[1]);
+ $display = parse_url($url, PHP_URL_HOST);
+ if (!$display) {
+ $display = $url;
+ }
+ return '' . esc_html($display) . '';
+ }, $text);
+ // Line breaks
+ $text = nl2br($text);
+ return $text;
+}
+
+function beep_relative_time($timestamp) {
+ $now = time();
+ $diff = $now - $timestamp;
+ if ($diff < 0) {
+ $diff = 0;
+ }
+ if ($diff < 60) {
+ return $diff . 's';
+ }
+ if ($diff < 3600) {
+ return floor($diff / 60) . 'm';
+ }
+ if ($diff < 86400) {
+ return floor($diff / 3600) . 'h';
+ }
+ if ($diff < 604800) {
+ return floor($diff / 86400) . 'd';
+ }
+ return gmdate('M j', $timestamp);
+}
+
+function beep_icon($name) {
+ $icons = [
+ 'reply' => '',
+ 'heart' => '',
+ 'heart_filled' => '',
+ 'share' => '',
+ 'beep_bird' => '',
+ ];
+ return isset($icons[$name]) ? $icons[$name] : '';
+}
+
+
+/**
+ * Render the thread/conversation modal (sits outside individual posts).
+ */
+function beep_render_thread_modal() {
+ return '' .
+ '' .
+ '
Loading conversation...
' .
+ '
' .
+ '
';
+}
+
+
+/**
+ * Render the GIF picker modal.
+ */
+function beep_render_gif_modal() {
+ return '' .
+ '' .
+ '
' .
+ '' .
+ '' .
+ '
' .
+ '
' .
+ '
';
+}
diff --git a/readme.txt b/readme.txt
new file mode 100755
index 0000000..f73f93c
--- /dev/null
+++ b/readme.txt
@@ -0,0 +1,81 @@
+=== Beep ===
+Contributors: sami
+Tags: microblog, twitter, tweets, social, comments, polls, elementor
+Requires at least: 6.0
+Tested up to: 6.9
+Requires PHP: 7.4
+Stable tag: 1.6.0
+License: GPLv2 or later
+
+A Twitter-style microblog for WordPress. Posts, likes, replies, polls, quotes, GIFs, and Gravatars. Use [beep_feed] shortcode or Elementor widget.
+
+== Description ==
+
+Beep adds a Twitter-style microblog to your WordPress site. Drop the
+[beep_feed] shortcode on any page or use the Elementor widget. Likes,
+threaded replies, polls, quotes, GIFs, and Latest/Top sort tabs are all built in.
+
+Features:
+* Twitter-style feed with [beep_feed] shortcode
+* Elementor drag-and-drop widget
+* Polls (create and vote)
+* Quote posts
+* GIF picker (Giphy integration)
+* Likes on beeps and replies
+* Nested threaded replies
+* Gravatar avatars
+* Admin settings page (Settings > Beep)
+* Customizable colors and branding via CSS variables
+* Bird-themed branding with custom icons
+
+== Installation ==
+
+1. Upload beep.zip via Plugins -> Add New -> Upload Plugin. Activate.
+2. Add [beep_feed] to a page, or use the Elementor widget.
+3. For multi-user signup, enable "Anyone can register" in Settings -> General.
+
+== Shortcode ==
+
+[beep_feed limit="50" header="Messages from" sort="latest"]
+
+== Changelog ==
+
+= 1.6.0 =
+* Elementor drag-and-drop widget for feed placement.
+* Admin settings page (Settings > Beep) for Giphy API key and branding.
+* Polls: create polls in beeps, vote on polls.
+* Quote posts: quote any beep.
+* GIF picker powered by Giphy.
+* Bird-themed redesign with custom bird icons and badges.
+* CSS variables for customizable colors.
+* Elementor widget registration.
+* Settings stored in wp_options.
+
+= 1.5.1 =
+* Twitter-style UI redesign with bird assets.
+* Beep bird badge on posts and replies.
+* CSS scope isolation improvements.
+
+= 1.5.0 =
+* Polls feature (Beep_Polls class, voting UI).
+* Bird wordmark logo in header.
+
+= 1.4.0 =
+* Like replies, not just beeps.
+* Nested replies (reply to replies, indented up to two levels).
+* Latest / Top sort tabs at the top of the feed.
+* Bird badge in the top-right of every beep and reply.
+* Polished tabs, focus states, action button styling.
+
+= 1.3.0 =
+* Feed header reads "Messages from" + Beep wordmark.
+* Avatar/textarea fixes baked in.
+
+= 1.2.0 =
+* Feed header shows Beep wordmark instead of small icon + Home text.
+
+= 1.1.0 =
+* New hand-drawn bird logo.
+
+= 1.0.0 =
+* Initial release.