diff --git a/package.json b/package.json
index 0a4709a2..70bd748f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "impro",
- "version": "0.20.23",
+ "version": "0.20.24",
"type": "module",
"scripts": {
"start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
diff --git a/src/css/style.css b/src/css/style.css
index 47bedb02..899fc469 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -2093,6 +2093,10 @@ container-link {
}
}
+.who-can-reply-badge-link {
+ color: var(--highlight-color);
+}
+
.post-text {
line-height: 1.3125;
margin-top: -4px;
@@ -6953,21 +6957,43 @@ sidebar-search {
.post-composer-bottom-bar {
display: flex;
- justify-content: space-between;
- align-items: center;
+ flex-direction: column;
+ align-items: stretch;
flex-shrink: 0;
margin-top: 16px;
- padding: 8px 16px calc(var(--safe-area-inset-bottom) + 8px);
+ padding: 8px 0 calc(var(--safe-area-inset-bottom) + 8px);
position: sticky;
bottom: 0;
background-color: light-dark(var(--white), var(--mid-gray));
}
+.post-composer-bottom-bar-main {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-right: 16px;
+ padding-left: 16px;
+}
+
+.interaction-settings-section .pill-radio-group label,
+.interaction-settings-section .interaction-settings-row {
+ min-height: 48px;
+}
+
+.post-composer-interaction-settings-row {
+ display: flex;
+ padding-bottom: 12px;
+ padding-right: 16px;
+ padding-left: 16px;
+ margin-bottom: 8px;
+ position: relative;
+}
+
/* workaround for iOS Safari subpixel rounding */
-.post-composer-bottom-bar::before {
+.post-composer-interaction-settings-row::after {
content: "";
position: absolute;
- top: 0;
+ bottom: 0;
left: 0;
right: 0;
height: 1px;
@@ -6988,6 +7014,11 @@ sidebar-search {
align-items: center;
}
+.composer-interaction-settings-button .icon {
+ width: 15px;
+ height: 15px;
+}
+
.post-composer-post {
position: relative;
transition: opacity 0.15s;
@@ -8390,7 +8421,7 @@ context-menu-item-group > context-menu-item > button {
margin: 0;
}
-.muted-word-checkbox-row {
+.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
@@ -8404,11 +8435,11 @@ context-menu-item-group > context-menu-item > button {
font-weight: 500;
}
-.muted-word-checkbox-row:has(input:checked) {
+.checkbox-row:has(input:checked) {
background: color-mix(in srgb, var(--highlight-color) 15%, transparent);
}
-.muted-word-checkbox-row input[type="checkbox"] {
+.checkbox-row input[type="checkbox"] {
accent-color: var(--highlight-color);
background-color: transparent;
margin: 0;
@@ -10082,6 +10113,204 @@ toggle-switch {
width: 20px;
}
+/* Post Interaction Settings Dialog */
+
+.post-interaction-settings-dialog::before {
+ z-index: 1;
+}
+
+.post-interaction-settings-dialog[open] {
+ display: flex;
+ flex-direction: column;
+}
+
+@media (max-width: 799px) {
+ .post-interaction-settings-dialog {
+ max-height: calc(100dvh - var(--sheet-top));
+ }
+}
+
+@media (min-width: 800px) {
+ .post-interaction-settings-dialog {
+ max-width: 440px;
+ }
+}
+
+.post-interaction-settings-dialog-content {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-height: 0;
+ padding: 24px 20px;
+ padding-top: 28px;
+ position: relative;
+}
+
+@media (min-width: 800px) {
+ .post-interaction-settings-dialog-content {
+ padding-top: 24px;
+ }
+}
+
+.post-interaction-settings-dialog-close {
+ display: none;
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--text-color);
+ padding: 4px 8px;
+ opacity: 0.6;
+}
+
+@media (min-width: 800px) {
+ .post-interaction-settings-dialog-close {
+ display: flex;
+ align-items: center;
+ }
+}
+
+@media (hover: hover) {
+ .post-interaction-settings-dialog-close:hover {
+ opacity: 1;
+ }
+}
+
+.post-interaction-settings-dialog-body {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.post-interaction-settings-dialog-header {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.post-interaction-settings-dialog-title {
+ font-size: 1.3rem;
+ font-weight: 700;
+ margin: 0;
+}
+
+.post-interaction-settings-dialog-subtitle {
+ margin: 0;
+ color: var(--text-color-secondary);
+ font-size: 0.95rem;
+}
+
+.interaction-settings-section-title {
+ font-size: 0.95rem;
+ font-weight: 600;
+ margin: 0 0 8px;
+}
+
+.interaction-settings-rows {
+ display: flex;
+ flex-direction: column;
+}
+
+.interaction-settings-row {
+ margin-bottom: 8px;
+}
+
+.interaction-settings-row-disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.interaction-settings-list-avatar {
+ width: 24px;
+ height: 24px;
+ border-radius: 4px;
+ flex-shrink: 0;
+}
+
+.interaction-settings-list-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.interaction-settings-preserved-row {
+ justify-content: space-between;
+ color: var(--text-color-secondary);
+}
+
+.interaction-settings-lists-toggle {
+ padding: 8px 0;
+ color: var(--text-color);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.interaction-settings-lists-message {
+ color: var(--text-color-secondary);
+ font-size: 0.9rem;
+ padding: 8px 0;
+}
+
+.interaction-settings-quote-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 12px 12px;
+ border-radius: var(--card-border-radius);
+ background: var(--post-hover-color);
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.interaction-settings-quote-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.interaction-settings-quote-label app-icon {
+ display: flex;
+ width: 18px;
+ height: 18px;
+}
+
+.interaction-settings-default-note {
+ color: var(--text-color-muted);
+ font-size: 14px;
+}
+
+.interaction-settings-save-default-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.interaction-settings-save-default-row input[type="checkbox"] {
+ accent-color: var(--highlight-color);
+ margin: 0;
+}
+
+.interaction-settings-error {
+ color: var(--error-color);
+ font-size: 0.9rem;
+}
+
+.interaction-settings-save {
+ width: 100%;
+ padding: 12px 20px;
+ text-align: center;
+}
+
+.interaction-settings-save:disabled {
+ cursor: not-allowed;
+}
+
.chat-header-buttons {
display: flex;
align-items: center;
diff --git a/src/js/api.js b/src/js/api.js
index 69051e56..39bfb943 100644
--- a/src/js/api.js
+++ b/src/js/api.js
@@ -30,6 +30,10 @@ export class ApiError extends Error {
}
}
+export function isRecordNotFoundError(error) {
+ return error instanceof ApiError && error.data?.error === "RecordNotFound";
+}
+
class PublicSession {
constructor() {
this.serviceEndpoint = PUBLIC_SERVICE_ENDPOINT_URL;
@@ -1369,6 +1373,62 @@ export class Api {
return res.data;
}
+ async getThreadgateRecord(rkey) {
+ const res = await this.request("com.atproto.repo.getRecord", {
+ query: {
+ repo: this.session.did,
+ collection: "app.bsky.feed.threadgate",
+ rkey,
+ },
+ });
+ return res.data;
+ }
+
+ async putThreadgateRecord(rkey, record, swapRecord) {
+ const res = await this.request("com.atproto.repo.putRecord", {
+ method: "POST",
+ body: {
+ repo: this.session.did,
+ collection: "app.bsky.feed.threadgate",
+ rkey,
+ record: {
+ $type: "app.bsky.feed.threadgate",
+ ...record,
+ },
+ swapRecord: swapRecord ?? null,
+ },
+ });
+ return res.data;
+ }
+
+ async getPostgateRecord(rkey) {
+ const res = await this.request("com.atproto.repo.getRecord", {
+ query: {
+ repo: this.session.did,
+ collection: "app.bsky.feed.postgate",
+ rkey,
+ },
+ });
+ return res.data;
+ }
+
+ async putPostgateRecord(rkey, record, swapRecord) {
+ const res = await this.request("com.atproto.repo.putRecord", {
+ method: "POST",
+ body: {
+ repo: this.session.did,
+ collection: "app.bsky.feed.postgate",
+ rkey,
+ record: {
+ $type: "app.bsky.feed.postgate",
+ ...record,
+ },
+ swapRecord: swapRecord ?? null,
+ },
+ });
+ return res.data;
+ }
+
async createModerationReport({ reasonType, reason, subject, labelerDid }) {
const body = {
reasonType,
diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js
index a4ffa583..a58bd04d 100644
--- a/src/js/components/post-composer.js
+++ b/src/js/components/post-composer.js
@@ -54,6 +54,9 @@ import {
getDraftDeviceId,
} from "/js/drafts.js";
import { ApiError } from "/js/api.js";
+import { globeIconTemplate } from "/js/templates/icons/globeIcon.template.js";
+import { usersIconTemplate } from "/js/templates/icons/usersIcon.template.js";
+import "/js/components/post-interaction-settings-dialog.js";
import "/js/components/rich-text-input.js";
import "/js/components/image-alt-text-dialog.js";
import "/js/components/emoji-picker-dialog.js";
@@ -423,10 +426,10 @@ class PostComposer extends Component {
this._draftId = null;
this._isDirty = false;
this._originalLocalRefs = null;
- // Pass through unsupported fields on drafts
- this._draftPassthrough = null;
this._sendAbortController = null;
this._sendAttemptId = null;
+ this._interactionSettingsTouched = false;
+ this._defaultInteractionSettings = null;
// postId -> AbortController for that post's in-flight video upload
this._videoAbortControllers = new Map();
this.state = new ReactiveStore("postComposer");
@@ -446,6 +449,8 @@ class PostComposer extends Component {
);
this._pendingDraftsEnabled = null;
this.state.$isDraggingFiles = new Signal.State(false);
+ this.state.$threadgateAllow = new Signal.State(null);
+ this.state.$postgateEmbeddingRules = new Signal.State(null);
this._dragAndDropObserver = null;
this._disposers = [
effect(() => {
@@ -573,6 +578,10 @@ class PostComposer extends Component {
const activePost = posts[activePostIndex];
const isThread = posts.length > 1;
const isDraggingFiles = this.state.$isDraggingFiles.get();
+ const threadgateAllow = this.state.$threadgateAllow.get();
+ const postgateEmbeddingRules = this.state.$postgateEmbeddingRules.get();
+ const isInteractionLimited =
+ threadgateAllow !== null || postgateEmbeddingRules?.length > 0;
const currentCharCount = graphemeCount(activePost.text);
const charCountPercentage = Math.min(
@@ -728,85 +737,107 @@ class PostComposer extends Component {
)}
-
-
this.handleMediaSelect(e)}
- @cancel=${(e) => {
- e.stopPropagation();
- }}
- />
-
-
-
this.handleGifSelected(e.detail.gif)}
- >
-
+ ${!this.replyTo
+ ? html`
+
+
`
+ : ""}
+
+
+
this.handleMediaSelect(e)}
+ @cancel=${(e) => {
+ e.stopPropagation();
+ }}
+ />
+
-
{
- this.handleEmojiSelect(e.detail.emoji);
- e.currentTarget.close();
- }}
- >
-
-
-
- ${canAddPost
- ? html`
Options:
-