diff --git a/docs/issues/2026-03-23-tag-blur-attribute/definition.md b/docs/issues/2026-03-23-tag-blur-attribute/definition.md new file mode 100644 index 000000000..cc384131f --- /dev/null +++ b/docs/issues/2026-03-23-tag-blur-attribute/definition.md @@ -0,0 +1,98 @@ +## Background & Context + +Memos has a content-blur feature: when a memo's tag list contains the literal string `NSFW` +(case-insensitive), the memo body is rendered with a `blur-lg` CSS class and a click-to-reveal +overlay is shown. This was simplified in v0.26.x from a previous admin-configurable system +(which had an on/off toggle and a custom-tag-list) down to a single hardcoded tag name. + +In the same release cycle, an `InstanceTagsSetting` system was introduced that lets admins attach +metadata (currently only `background_color`) to tag name patterns via a regex-keyed map. This +system has its own proto definitions, store layer, API service handlers, frontend context, utility +library, and settings UI — all independent of the blur feature. + +A sponsor raised (orgs/usememos/discussions/5708) that the hardcoded tag name is inconvenient: +users who organised their content under a different tag (e.g. a non-English word, a project- +specific label, or simply a term they prefer) must re-tag all existing memos just to use the blur +feature. Community comments echo the same concern and additionally ask for the ability to disable +the blur globally. + +## Issue Statement + +The memo content-blur trigger is evaluated exclusively against the hardcoded string `"NSFW"` +(case-insensitive) in `MemoView.tsx`, with no connection to the `InstanceTagsSetting` system, +making it impossible for an administrator to designate any other tag name — or set of tag name +patterns — as a blur trigger, and preventing users from re-using existing tag taxonomies to +activate content blurring. + +## Current State + +**Blur detection — frontend** + +| File | Lines | Behaviour | +|------|-------|-----------| +| `web/src/components/MemoView/MemoView.tsx` | 27–30 | `const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false;` — single hardcoded string comparison | +| `web/src/components/MemoView/MemoViewContext.tsx` | 16–19 | Context shape exposes `nsfw: boolean`, `showNSFWContent: boolean`, `toggleNsfwVisibility` | +| `web/src/components/MemoView/components/MemoBody.tsx` | 11–23, 37, 53 | Applies `blur-lg transition-all duration-200` when `nsfw && !showNSFWContent`; renders `NsfwOverlay` button using i18n key `memo.click-to-show-nsfw-content` | +| `web/src/components/MemoPreview/MemoPreview.tsx` | 24–27 | Stub context value: `nsfw: false`, `showNSFWContent: false` — blur never active in preview | + +**Localisation strings that contain the "NSFW" term** + +| File | Keys | +|------|------| +| `web/src/locales/en.json` (and ~30 other locale files) | `memo.click-to-hide-nsfw-content`, `memo.click-to-show-nsfw-content`, `settings.enable-blur-nsfw-content` | + +Note: most non-English translations already use "sensitive content" rather than "NSFW" in these +keys; English is the outlier. + +**Tag metadata system — proto** + +| File | Lines | Content | +|------|-------|---------| +| `proto/api/v1/instance_service.proto` | 168–181 | `message TagMetadata { google.type.Color background_color = 1; }` nested inside `InstanceSetting`; `TagsSetting` is a `map` | +| `proto/store/instance_setting.proto` | 113–124 | `message InstanceTagMetadata { google.type.Color background_color = 1; }` inside `InstanceTagsSetting` | + +**Tag metadata system — backend** + +| File | Lines | Content | +|------|-------|---------| +| `store/instance_setting.go` | 166–192 | `GetInstanceTagsSetting()` retrieves and caches the tags map | +| `server/router/api/v1/instance_service.go` | 300–328 | `convertInstanceTagsSettingFromStore()` / `convertInstanceTagsSettingToStore()` convert between store and API representations, field-by-field | +| `server/router/api/v1/instance_service.go` | 387–409 | `validateInstanceTagsSetting()` validates each key as a regex pattern and the color value | + +**Tag metadata system — frontend** + +| File | Lines | Content | +|------|-------|---------| +| `web/src/lib/tag.ts` | 28–43 | `findTagMetadata(tag, tagsSetting)` — exact-match then regex-match lookup returning `TagMetadata \| undefined` | +| `web/src/components/MemoContent/Tag.tsx` | 23–38 | Calls `findTagMetadata()` to apply `background_color` to inline tag chips | +| `web/src/components/Settings/TagsSection.tsx` | 36–206 | Admin settings UI for managing the tag→metadata map; currently shows only a colour picker per tag | +| `web/src/contexts/InstanceContext.tsx` | 83–99 | `tagsSetting` selector and fetch during app initialisation | + +## Non-Goals + +- Redesigning or replacing the `InstanceTagsSetting` proto or store structure beyond adding one field. +- Providing a per-user (as opposed to per-instance) blur preference. +- Changing how background-color metadata is stored, validated, or rendered. +- Adding a global on/off toggle for blurring (separate from per-tag configuration). +- Modifying the blur visual effect (CSS class, animation, overlay button layout). +- Migrating or auto-converting any existing memos that were tagged with `NSFW`. +- Changing non-English locale strings that already use neutral terminology. + +## Open Questions + +1. Should the `blur_content` field in tag metadata be configurable per-tag only by admins, or also by individual users via user-level tag settings? (default: admin-only, matching the existing `InstanceTagsSetting` access model) + +2. When a memo has multiple tags and more than one of them has `blur_content = true`, should the blur activate if _any_ matching tag has the flag set, or only if _all_ matching tags do? (default: any — OR semantics, consistent with the current single-tag check) + +3. Should there be a migration that automatically sets `blur_content = true` for any existing `InstanceTagsSetting` entry whose key is `"NSFW"` (case-insensitive)? (default: no automatic migration; admins reconfigure manually) + +4. What should the English-locale i18n key strings say, given that "NSFW" is to be avoided? (default: "Click to show sensitive content" / "Click to hide sensitive content") + +## Scope + +**M** — the change adds one `bool` field to two existing proto messages, threads it through two +existing conversion functions in the backend, replaces one hardcoded string comparison in +`MemoView.tsx` with a call to the already-present `findTagMetadata()` utility, adds a checkbox to +the existing `TagsSection.tsx` settings UI, and renames three i18n keys. All required patterns +(field addition, conversion, `findTagMetadata` lookup, settings UI checkbox) already exist in the +codebase. diff --git a/docs/issues/2026-03-23-tag-blur-attribute/execution.md b/docs/issues/2026-03-23-tag-blur-attribute/execution.md new file mode 100644 index 000000000..31ef40487 --- /dev/null +++ b/docs/issues/2026-03-23-tag-blur-attribute/execution.md @@ -0,0 +1,63 @@ +## Execution Log + +### T1: Add blur_content field to proto messages + +**Status**: Completed +**Files Changed**: `proto/api/v1/instance_service.proto`, `proto/store/instance_setting.proto` +**Validation**: `buf lint` — PASS +**Path Corrections**: None +**Deviations**: None + +### T2: Regenerate proto code + +**Status**: Completed +**Files Changed**: `proto/gen/` (Go + OpenAPI), `web/src/types/proto/` (TypeScript) +**Validation**: `grep blur_content` in generated files — PASS (field present in Go, TS, OpenAPI) +**Path Corrections**: None +**Deviations**: None + +### T3: Thread blur_content through backend conversions + +**Status**: Completed +**Files Changed**: `server/router/api/v1/instance_service.go` +**Validation**: `go build ./...` — PASS +**Path Corrections**: None +**Deviations**: None + +### T4: Replace hardcoded NSFW check with tag metadata lookup + +**Status**: Completed +**Files Changed**: `web/src/components/MemoView/MemoView.tsx`, `web/src/components/MemoView/MemoViewContext.tsx`, `web/src/components/MemoView/components/MemoBody.tsx`, `web/src/components/MemoPreview/MemoPreview.tsx` +**Validation**: `pnpm lint` — PASS +**Path Corrections**: i18n key update (T6) was pulled forward to unblock TypeScript type checking, since the i18n key type is statically checked. +**Deviations**: None + +### T5: Add blur checkbox to TagsSection settings UI + +**Status**: Completed +**Files Changed**: `web/src/components/Settings/TagsSection.tsx` +**Validation**: `pnpm lint` — PASS +**Path Corrections**: None +**Deviations**: None + +### T6: Update English i18n keys + +**Status**: Completed +**Files Changed**: `web/src/locales/en.json` +**Validation**: `grep -c "nsfw\|NSFW" en.json` — returns 0, PASS +**Path Corrections**: Executed during T4/T5 to unblock type checking. Added `setting.tags.blur-content` key (not in original plan but required by T5's new checkbox column). +**Deviations**: None + +## Completion Declaration + +**All tasks completed successfully.** + +Summary of changes: +- Added `bool blur_content = 2` to both API and store proto TagMetadata messages +- Regenerated Go, TypeScript, and OpenAPI code +- Threaded `blur_content` through `convertInstanceTagsSettingFromStore()` and `convertInstanceTagsSettingToStore()` +- Replaced hardcoded `tag.toUpperCase() === "NSFW"` with `findTagMetadata(tag, tagsSetting)?.blurContent` lookup +- Renamed context fields: `nsfw` → `blurred`, `showNSFWContent` → `showBlurredContent`, `toggleNsfwVisibility` → `toggleBlurVisibility` +- Renamed `NsfwOverlay` → `BlurOverlay` component +- Expanded TagsSection local state to track `{ color, blur }` per tag and added a "Blur content" checkbox column +- Updated English i18n: renamed NSFW keys to "sensitive content", removed unused key, added `blur-content` setting key diff --git a/docs/issues/2026-03-23-tag-blur-attribute/plan.md b/docs/issues/2026-03-23-tag-blur-attribute/plan.md new file mode 100644 index 000000000..81754ec3c --- /dev/null +++ b/docs/issues/2026-03-23-tag-blur-attribute/plan.md @@ -0,0 +1,89 @@ +## Task List + +T1: Add blur_content field to proto messages [S] — T2: Regenerate proto code [S] — T3: Thread blur_content through backend conversions [S] — T4: Replace hardcoded NSFW check with tag metadata lookup [M] — T5: Add blur checkbox to TagsSection settings UI [S] — T6: Update English i18n keys [S] + +### T1: Add blur_content field to proto messages [S] + +**Objective**: Add a `bool blur_content` field to both the API and store proto TagMetadata messages. +**Files**: `proto/api/v1/instance_service.proto`, `proto/store/instance_setting.proto` +**Implementation**: +- In `proto/api/v1/instance_service.proto` (~line 171), add `bool blur_content = 2;` to `message TagMetadata` after `background_color` +- In `proto/store/instance_setting.proto` (~line 115), add `bool blur_content = 2;` to `message InstanceTagMetadata` after `background_color` +**Validation**: `cd proto && buf lint` — no errors + +### T2: Regenerate proto code [S] + +**Objective**: Regenerate Go + TypeScript + OpenAPI from updated proto definitions. +**Files**: `proto/gen/` (generated), `web/src/types/proto/` (generated) +**Implementation**: Run `cd proto && buf generate` +**Dependencies**: T1 +**Validation**: `grep -r "blur_content\|blurContent" proto/gen/ web/src/types/proto/ | head -10` — shows new field in generated Go and TS files + +### T3: Thread blur_content through backend conversion functions [S] + +**Objective**: Pass `blur_content` through the store↔API conversion functions so the field round-trips correctly. +**Files**: `server/router/api/v1/instance_service.go` +**Implementation**: +- In `convertInstanceTagsSettingFromStore()` (~line 306): add `BlurContent: metadata.GetBlurContent()` to the `InstanceSetting_TagMetadata` struct literal +- In `convertInstanceTagsSettingToStore()` (~line 321): add `BlurContent: metadata.GetBlurContent()` to the `InstanceTagMetadata` struct literal +**Dependencies**: T2 +**Validation**: `cd /Users/steven/Projects/usememos/memos && go build ./...` — compiles without errors + +### T4: Replace hardcoded NSFW check with tag metadata lookup [M] + +**Objective**: Replace the hardcoded `tag.toUpperCase() === "NSFW"` check with a lookup against `InstanceTagsSetting` via the existing `findTagMetadata()` utility, so any tag with `blur_content: true` triggers the blur. +**Size**: M (3 files, moderate logic) +**Files**: +- Modify: `web/src/components/MemoView/MemoView.tsx` +- Modify: `web/src/components/MemoView/MemoViewContext.tsx` +- Modify: `web/src/components/MemoView/components/MemoBody.tsx` +- Modify: `web/src/components/MemoPreview/MemoPreview.tsx` +**Implementation**: +1. In `MemoView.tsx`: + - Import `useInstance` from `@/contexts/InstanceContext` and `findTagMetadata` from `@/lib/tag` + - Replace `const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false;` with a check that iterates `memoData.tags` and uses `findTagMetadata(tag, tagsSetting)?.blurContent` — OR semantics (any match triggers blur) + - Rename state/variables: `showNSFWContent` → `showBlurredContent`, `nsfw` → `blurred`, `toggleNsfwVisibility` → `toggleBlurVisibility` +2. In `MemoViewContext.tsx`: + - Rename interface fields: `nsfw` → `blurred`, `showNSFWContent` → `showBlurredContent`, `toggleNsfwVisibility` → `toggleBlurVisibility` +3. In `MemoBody.tsx`: + - Update destructured context fields to use new names (`blurred`, `showBlurredContent`, `toggleBlurVisibility`) + - Rename `NsfwOverlay` component to `BlurOverlay` + - Change i18n key from `memo.click-to-show-nsfw-content` to `memo.click-to-show-sensitive-content` +4. In `MemoPreview.tsx`: + - Update stub context to use new field names (`blurred`, `showBlurredContent`, `toggleBlurVisibility`) +**Boundaries**: Do NOT change blur CSS classes, animation, or overlay layout +**Dependencies**: T2 +**Validation**: `cd web && pnpm lint` — no type or lint errors + +### T5: Add blur checkbox to TagsSection settings UI [S] + +**Objective**: Add a "Blur content" checkbox column to the tag settings table so admins can toggle `blur_content` per tag pattern. +**Files**: `web/src/components/Settings/TagsSection.tsx` +**Implementation**: +- Expand `localTags` state from `Record` (hex only) to `Record` to track both fields +- Update `useEffect` sync, `originalHexMap` comparison, `handleColorChange`, `handleRemoveTag`, `handleAddTag` to work with the new shape +- Add a new `[newTagBlur, setNewTagBlur]` state for the add-tag row (default `false`) +- In `handleSave`, pass `blurContent` when creating `InstanceSetting_TagMetadata` +- Add a new column to `SettingTable` between "Background color" and "Actions": header `t("setting.tags.blur-content")`, renders a checkbox bound to `localTags[row.name].blur` +- Add the i18n key `setting.tags.blur-content` to `en.json` with value `"Blur content"` +**Dependencies**: T2 +**Validation**: `cd web && pnpm lint` — no type or lint errors + +### T6: Update English i18n keys [S] + +**Objective**: Rename NSFW-specific i18n keys in `en.json` to use neutral "sensitive content" terminology. +**Files**: `web/src/locales/en.json` +**Implementation**: +- Change key `memo.click-to-show-nsfw-content` → `memo.click-to-show-sensitive-content` with value `"Click to show sensitive content"` +- Change key `memo.click-to-hide-nsfw-content` → `memo.click-to-hide-sensitive-content` with value `"Click to hide sensitive content"` (dead key but renamed for consistency) +- The key `settings.enable-blur-nsfw-content` is unused in code — remove it +**Dependencies**: T4 (key rename must match code references) +**Validation**: `grep -c "nsfw" web/src/locales/en.json` — returns `0` + +## Out-of-Scope Tasks + +- Updating non-English locale files (per non-goals: "Changing non-English locale strings that already use neutral terminology") +- Adding automatic migration for existing NSFW tag entries +- Per-user blur preferences +- Global on/off toggle for blurring +- Modifying blur visual effect (CSS, animation, overlay layout) diff --git a/proto/api/v1/instance_service.proto b/proto/api/v1/instance_service.proto index 836a2415e..6dc8f9fb2 100644 --- a/proto/api/v1/instance_service.proto +++ b/proto/api/v1/instance_service.proto @@ -169,6 +169,8 @@ message InstanceSetting { message TagMetadata { // Background color for the tag label. google.type.Color background_color = 1; + // Whether memos with this tag should have their content blurred. + bool blur_content = 2; } // Tag metadata configuration. diff --git a/proto/gen/api/v1/instance_service.pb.go b/proto/gen/api/v1/instance_service.pb.go index 307e0aee1..37d48328c 100644 --- a/proto/gen/api/v1/instance_service.pb.go +++ b/proto/gen/api/v1/instance_service.pb.go @@ -761,8 +761,10 @@ type InstanceSetting_TagMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` // Background color for the tag label. BackgroundColor *color.Color `protobuf:"bytes,1,opt,name=background_color,json=backgroundColor,proto3" json:"background_color,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether memos with this tag should have their content blurred. + BlurContent bool `protobuf:"varint,2,opt,name=blur_content,json=blurContent,proto3" json:"blur_content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *InstanceSetting_TagMetadata) Reset() { @@ -802,9 +804,20 @@ func (x *InstanceSetting_TagMetadata) GetBackgroundColor() *color.Color { return nil } +func (x *InstanceSetting_TagMetadata) GetBlurContent() bool { + if x != nil { + return x.BlurContent + } + return false +} + // Tag metadata configuration. type InstanceSetting_TagsSetting struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // Map of tag name pattern to tag metadata. + // Each key is treated as an anchored regular expression (^pattern$), + // so a single entry like "project/.*" matches all tags under that prefix. + // Exact tag names are also valid (they are trivially valid regex patterns). Tags map[string]*InstanceSetting_TagMetadata `protobuf:"bytes,1,rep,name=tags,proto3" json:"tags,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1166,7 +1179,7 @@ const file_api_v1_instance_service_proto_rawDesc = "" + "\x04demo\x18\x03 \x01(\bR\x04demo\x12!\n" + "\finstance_url\x18\x06 \x01(\tR\vinstanceUrl\x12(\n" + "\x05admin\x18\a \x01(\v2\x12.memos.api.v1.UserR\x05admin\"\x1b\n" + - "\x19GetInstanceProfileRequest\"\xe0\x15\n" + + "\x19GetInstanceProfileRequest\"\x83\x16\n" + "\x0fInstanceSetting\x12\x17\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12W\n" + "\x0fgeneral_setting\x18\x02 \x01(\v2,.memos.api.v1.InstanceSetting.GeneralSettingH\x00R\x0egeneralSetting\x12W\n" + @@ -1208,9 +1221,10 @@ const file_api_v1_instance_service_proto_rawDesc = "" + "\x18display_with_update_time\x18\x02 \x01(\bR\x15displayWithUpdateTime\x120\n" + "\x14content_length_limit\x18\x03 \x01(\x05R\x12contentLengthLimit\x127\n" + "\x18enable_double_click_edit\x18\x04 \x01(\bR\x15enableDoubleClickEdit\x12\x1c\n" + - "\treactions\x18\a \x03(\tR\treactions\x1aL\n" + + "\treactions\x18\a \x03(\tR\treactions\x1ao\n" + "\vTagMetadata\x12=\n" + - "\x10background_color\x18\x01 \x01(\v2\x12.google.type.ColorR\x0fbackgroundColor\x1a\xba\x01\n" + + "\x10background_color\x18\x01 \x01(\v2\x12.google.type.ColorR\x0fbackgroundColor\x12!\n" + + "\fblur_content\x18\x02 \x01(\bR\vblurContent\x1a\xba\x01\n" + "\vTagsSetting\x12G\n" + "\x04tags\x18\x01 \x03(\v23.memos.api.v1.InstanceSetting.TagsSetting.TagsEntryR\x04tags\x1ab\n" + "\tTagsEntry\x12\x10\n" + diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index 6a1b89527..d8a033ae0 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -2399,6 +2399,9 @@ components: allOf: - $ref: '#/components/schemas/Color' description: Background color for the tag label. + blurContent: + type: boolean + description: Whether memos with this tag should have their content blurred. description: Metadata for a tag. InstanceSetting_TagsSetting: type: object @@ -2407,6 +2410,11 @@ components: type: object additionalProperties: $ref: '#/components/schemas/InstanceSetting_TagMetadata' + description: |- + Map of tag name pattern to tag metadata. + Each key is treated as an anchored regular expression (^pattern$), + so a single entry like "project/.*" matches all tags under that prefix. + Exact tag names are also valid (they are trivially valid regex patterns). description: Tag metadata configuration. ListAllUserStatsResponse: type: object diff --git a/proto/gen/store/instance_setting.pb.go b/proto/gen/store/instance_setting.pb.go index 8db4d15c1..d51be76ae 100644 --- a/proto/gen/store/instance_setting.pb.go +++ b/proto/gen/store/instance_setting.pb.go @@ -756,8 +756,10 @@ type InstanceTagMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` // Background color for the tag label. BackgroundColor *color.Color `protobuf:"bytes,1,opt,name=background_color,json=backgroundColor,proto3" json:"background_color,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether memos with this tag should have their content blurred. + BlurContent bool `protobuf:"varint,2,opt,name=blur_content,json=blurContent,proto3" json:"blur_content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *InstanceTagMetadata) Reset() { @@ -797,8 +799,19 @@ func (x *InstanceTagMetadata) GetBackgroundColor() *color.Color { return nil } +func (x *InstanceTagMetadata) GetBlurContent() bool { + if x != nil { + return x.BlurContent + } + return false +} + type InstanceTagsSetting struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // Map of tag name pattern to tag metadata. + // Each key is treated as an anchored regular expression (^pattern$), + // so a single entry like "project/.*" matches all tags under that prefix. + // Exact tag names are also valid (they are trivially valid regex patterns). Tags map[string]*InstanceTagMetadata `protobuf:"bytes,1,rep,name=tags,proto3" json:"tags,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1053,9 +1066,10 @@ const file_store_instance_setting_proto_rawDesc = "" + "\x18display_with_update_time\x18\x02 \x01(\bR\x15displayWithUpdateTime\x120\n" + "\x14content_length_limit\x18\x03 \x01(\x05R\x12contentLengthLimit\x127\n" + "\x18enable_double_click_edit\x18\x04 \x01(\bR\x15enableDoubleClickEdit\x12\x1c\n" + - "\treactions\x18\a \x03(\tR\treactions\"T\n" + + "\treactions\x18\a \x03(\tR\treactions\"w\n" + "\x13InstanceTagMetadata\x12=\n" + - "\x10background_color\x18\x01 \x01(\v2\x12.google.type.ColorR\x0fbackgroundColor\"\xb0\x01\n" + + "\x10background_color\x18\x01 \x01(\v2\x12.google.type.ColorR\x0fbackgroundColor\x12!\n" + + "\fblur_content\x18\x02 \x01(\bR\vblurContent\"\xb0\x01\n" + "\x13InstanceTagsSetting\x12>\n" + "\x04tags\x18\x01 \x03(\v2*.memos.store.InstanceTagsSetting.TagsEntryR\x04tags\x1aY\n" + "\tTagsEntry\x12\x10\n" + diff --git a/proto/store/instance_setting.proto b/proto/store/instance_setting.proto index b06a11585..a6701bd7a 100644 --- a/proto/store/instance_setting.proto +++ b/proto/store/instance_setting.proto @@ -113,6 +113,8 @@ message InstanceMemoRelatedSetting { message InstanceTagMetadata { // Background color for the tag label. google.type.Color background_color = 1; + // Whether memos with this tag should have their content blurred. + bool blur_content = 2; } message InstanceTagsSetting { diff --git a/server/router/api/v1/instance_service.go b/server/router/api/v1/instance_service.go index de123cfc6..3de44a199 100644 --- a/server/router/api/v1/instance_service.go +++ b/server/router/api/v1/instance_service.go @@ -305,6 +305,7 @@ func convertInstanceTagsSettingFromStore(setting *storepb.InstanceTagsSetting) * for tag, metadata := range setting.Tags { tags[tag] = &v1pb.InstanceSetting_TagMetadata{ BackgroundColor: metadata.GetBackgroundColor(), + BlurContent: metadata.GetBlurContent(), } } return &v1pb.InstanceSetting_TagsSetting{ @@ -320,6 +321,7 @@ func convertInstanceTagsSettingToStore(setting *v1pb.InstanceSetting_TagsSetting for tag, metadata := range setting.Tags { tags[tag] = &storepb.InstanceTagMetadata{ BackgroundColor: metadata.GetBackgroundColor(), + BlurContent: metadata.GetBlurContent(), } } return &storepb.InstanceTagsSetting{ diff --git a/web/src/components/MemoPreview/MemoPreview.tsx b/web/src/components/MemoPreview/MemoPreview.tsx index 3c1272039..de858c6dd 100644 --- a/web/src/components/MemoPreview/MemoPreview.tsx +++ b/web/src/components/MemoPreview/MemoPreview.tsx @@ -21,10 +21,10 @@ const STUB_CONTEXT: MemoViewContextValue = { parentPage: "/", isArchived: false, readonly: true, - showNSFWContent: false, - nsfw: false, + showBlurredContent: false, + blurred: false, openEditor: () => {}, - toggleNsfwVisibility: () => {}, + toggleBlurVisibility: () => {}, openPreview: () => {}, }; diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index d494b0f6b..a44fdad84 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -1,7 +1,9 @@ import { memo, useCallback, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; +import { useInstance } from "@/contexts/InstanceContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import { useUser } from "@/hooks/useUserQueries"; +import { findTagMetadata } from "@/lib/tag"; import { cn } from "@/lib/utils"; import { State } from "@/types/proto/api/v1/common_pb"; import { isSuperUser } from "@/utils/user"; @@ -19,15 +21,16 @@ const MemoView: React.FC = (props: MemoViewProps) => { const [showEditor, setShowEditor] = useState(false); const currentUser = useCurrentUser(); + const { tagsSetting } = useInstance(); const creator = useUser(memoData.creator).data; const isArchived = memoData.state === State.ARCHIVED; const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser); const parentPage = parentPageProp || "/"; - // NSFW content management: always blur content tagged with NSFW (case-insensitive) - const [showNSFWContent, setShowNSFWContent] = useState(false); - const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false; - const toggleNsfwVisibility = useCallback(() => setShowNSFWContent((prev) => !prev), []); + // Blur content when any tag has blur_content enabled in the instance tag settings. + const [showBlurredContent, setShowBlurredContent] = useState(false); + const blurred = memoData.tags?.some((tag) => findTagMetadata(tag, tagsSetting)?.blurContent) ?? false; + const toggleBlurVisibility = useCallback(() => setShowBlurredContent((prev) => !prev), []); const { previewState, openPreview, setPreviewOpen } = useImagePreview(); @@ -46,10 +49,10 @@ const MemoView: React.FC = (props: MemoViewProps) => { parentPage, isArchived, readonly, - showNSFWContent, - nsfw, + showBlurredContent, + blurred, openEditor, - toggleNsfwVisibility, + toggleBlurVisibility, openPreview, }), [ @@ -59,10 +62,10 @@ const MemoView: React.FC = (props: MemoViewProps) => { parentPage, isArchived, readonly, - showNSFWContent, - nsfw, + showBlurredContent, + blurred, openEditor, - toggleNsfwVisibility, + toggleBlurVisibility, openPreview, ], ); diff --git a/web/src/components/MemoView/MemoViewContext.tsx b/web/src/components/MemoView/MemoViewContext.tsx index 3cd7e06d1..c9df07c96 100644 --- a/web/src/components/MemoView/MemoViewContext.tsx +++ b/web/src/components/MemoView/MemoViewContext.tsx @@ -13,10 +13,10 @@ export interface MemoViewContextValue { parentPage: string; isArchived: boolean; readonly: boolean; - showNSFWContent: boolean; - nsfw: boolean; + showBlurredContent: boolean; + blurred: boolean; openEditor: () => void; - toggleNsfwVisibility: () => void; + toggleBlurVisibility: () => void; openPreview: (urls: string | string[], index?: number) => void; } diff --git a/web/src/components/MemoView/components/MemoBody.tsx b/web/src/components/MemoView/components/MemoBody.tsx index ac3fa1091..33839b19b 100644 --- a/web/src/components/MemoView/components/MemoBody.tsx +++ b/web/src/components/MemoView/components/MemoBody.tsx @@ -8,7 +8,7 @@ import { useMemoHandlers } from "../hooks"; import { useMemoViewContext } from "../MemoViewContext"; import type { MemoBodyProps } from "../types"; -const NsfwOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => { +const BlurOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => { const t = useTranslate(); return (
@@ -16,14 +16,14 @@ const NsfwOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => { type="button" className="rounded-lg border border-border bg-card px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-accent hover:bg-accent hover:text-foreground" > - {t("memo.click-to-show-nsfw-content")} + {t("memo.click-to-show-sensitive-content")}
); }; const MemoBody: React.FC = ({ compact }) => { - const { memo, parentPage, showNSFWContent, nsfw, readonly, openEditor, openPreview, toggleNsfwVisibility } = useMemoViewContext(); + const { memo, parentPage, showBlurredContent, blurred, readonly, openEditor, openPreview, toggleBlurVisibility } = useMemoViewContext(); const { handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ readonly, openEditor, openPreview }); @@ -34,7 +34,7 @@ const MemoBody: React.FC = ({ compact }) => {
= ({ compact }) => {
- {nsfw && !showNSFWContent && } + {blurred && !showBlurredContent && } ); }; diff --git a/web/src/components/Settings/TagsSection.tsx b/web/src/components/Settings/TagsSection.tsx index eb005b172..8c6ef8b68 100644 --- a/web/src/components/Settings/TagsSection.tsx +++ b/web/src/components/Settings/TagsSection.tsx @@ -33,23 +33,39 @@ const hexToColor = (hex: string) => blue: parseInt(hex.slice(5, 7), 16) / 255, }); +interface LocalTagMeta { + color: string; + blur: boolean; +} + const TagsSection = () => { const t = useTranslate(); const { tagsSetting: originalSetting, updateSetting, fetchSetting } = useInstance(); const { data: tagCounts = {} } = useTagCounts(false); - // Local state: map of tagName → hex color string for editing. - const [localTags, setLocalTags] = useState>(() => - Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, tagColorToHex(meta.backgroundColor)])), + // Local state: map of tagName → { color, blur } for editing. + const [localTags, setLocalTags] = useState>(() => + Object.fromEntries( + Object.entries(originalSetting.tags).map(([name, meta]) => [ + name, + { color: tagColorToHex(meta.backgroundColor), blur: meta.blurContent }, + ]), + ), ); const [newTagName, setNewTagName] = useState(""); const [newTagColor, setNewTagColor] = useState("#ffffff"); + const [newTagBlur, setNewTagBlur] = useState(false); // Sync local state when the fetched setting arrives (the fetch is async and // completes after mount, so localTags would be empty without this sync). useEffect(() => { setLocalTags( - Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, tagColorToHex(meta.backgroundColor)])), + Object.fromEntries( + Object.entries(originalSetting.tags).map(([name, meta]) => [ + name, + { color: tagColorToHex(meta.backgroundColor), blur: meta.blurContent }, + ]), + ), ); }, [originalSetting.tags]); @@ -68,14 +84,24 @@ const TagsSection = () => { [localTags], ); - const originalHexMap = useMemo( - () => Object.fromEntries(Object.entries(originalSetting.tags).map(([name, meta]) => [name, tagColorToHex(meta.backgroundColor)])), + const originalMetaMap = useMemo( + () => + Object.fromEntries( + Object.entries(originalSetting.tags).map(([name, meta]) => [ + name, + { color: tagColorToHex(meta.backgroundColor), blur: meta.blurContent }, + ]), + ), [originalSetting.tags], ); - const hasChanges = !isEqual(localTags, originalHexMap); + const hasChanges = !isEqual(localTags, originalMetaMap); const handleColorChange = (tagName: string, hex: string) => { - setLocalTags((prev) => ({ ...prev, [tagName]: hex })); + setLocalTags((prev) => ({ ...prev, [tagName]: { ...prev[tagName], color: hex } })); + }; + + const handleBlurChange = (tagName: string, blur: boolean) => { + setLocalTags((prev) => ({ ...prev, [tagName]: { ...prev[tagName], blur } })); }; const handleRemoveTag = (tagName: string) => { @@ -97,17 +123,18 @@ const TagsSection = () => { toast.error(t("setting.tags.invalid-regex")); return; } - setLocalTags((prev) => ({ ...prev, [name]: newTagColor })); + setLocalTags((prev) => ({ ...prev, [name]: { color: newTagColor, blur: newTagBlur } })); setNewTagName(""); setNewTagColor("#ffffff"); + setNewTagBlur(false); }; const handleSave = async () => { try { const tags = Object.fromEntries( - Object.entries(localTags).map(([name, hex]) => [ + Object.entries(localTags).map(([name, meta]) => [ name, - create(InstanceSetting_TagMetadataSchema, { backgroundColor: hexToColor(hex) }), + create(InstanceSetting_TagMetadataSchema, { backgroundColor: hexToColor(meta.color), blurContent: meta.blur }), ]), ); await updateSetting( @@ -144,12 +171,24 @@ const TagsSection = () => { handleColorChange(row.name, e.target.value)} /> ), }, + { + key: "blur", + header: t("setting.tags.blur-content"), + render: (_, row: { name: string }) => ( + handleBlurChange(row.name, e.target.checked)} + /> + ), + }, { key: "actions", header: "", @@ -188,6 +227,15 @@ const TagsSection = () => { value={newTagColor} onChange={(e) => setNewTagColor(e.target.value)} /> +