diff --git a/js/components/src/components/chat/mod-view.tsx b/js/components/src/components/chat/mod-view.tsx index 7be2933e..d1d15796 100644 --- a/js/components/src/components/chat/mod-view.tsx +++ b/js/components/src/components/chat/mod-view.tsx @@ -274,9 +274,7 @@ function ModViewContent({ { if (!streamerDID) return; - pinChatMessage(message.uri, streamerDID, { - duration: "forever", - }) + pinChatMessage(message.uri, streamerDID) .then(() => { toast.show("Comment pinned", "", { duration: 3 }); onOpenChange?.(false); @@ -302,7 +300,6 @@ function ModViewContent({ ); pinChatMessage(message.uri, streamerDID, { expiresAt: expiresAt.toISOString(), - livestream: livestreamUri, }) .then(() => { toast.show("Comment pinned", "", { duration: 3 }); diff --git a/js/components/src/livestream-store/chat.tsx b/js/components/src/livestream-store/chat.tsx index d5c6efe7..3b376a01 100644 --- a/js/components/src/livestream-store/chat.tsx +++ b/js/components/src/livestream-store/chat.tsx @@ -491,7 +491,7 @@ export const usePinChatMessage = () => { return async ( messageUri: string, streamerDID: string, - options?: { expiresAt?: string; duration?: string; livestream?: string }, + options?: { expiresAt?: string; livestream?: string }, ) => { if (!agent || !agent.did) { throw new Error("No PDS agent or user DID found"); @@ -499,7 +499,6 @@ export const usePinChatMessage = () => { const extra: Record = {}; if (options?.expiresAt) extra.expiresAt = options.expiresAt; - if (options?.duration) extra.duration = options.duration; if (options?.livestream) extra.livestream = options.livestream; // If streamer, create directly diff --git a/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-pinnedrecord.md b/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-pinnedrecord.md index a39824a2..49f205f4 100644 --- a/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-pinnedrecord.md +++ b/js/docs/src/content/docs/lex-reference/chat/place-stream-chat-pinnedrecord.md @@ -19,14 +19,13 @@ Record pinning a chat message for prominent display. **Record Properties:** -| Name | Type | Req'd | Description | Constraints | -| --------------- | -------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `pinnedMessage` | `string` | ✅ | AT-URI of the pinned chat message. | Format: `at-uri` | -| `pinnedBy` | `string` | ❌ | DID of the user who pinned the message. | Format: `did` | -| `createdAt` | `string` | ✅ | When this pin was created. | Format: `datetime` | -| `expiresAt` | `string` | ❌ | Optional expiration time. If set, the pin is considered inactive after this time. | Format: `datetime` | -| `duration` | `string` | ❌ | How long this pin lasts when expiresAt is not set. 'streamEnd' (the default when unset) keeps the pin active until the livestream referenced by 'livestream' ends. 'forever' keeps the pin active across streams until manually unpinned. | Known Values: `streamEnd`, `forever` | -| `livestream` | `string` | ❌ | AT-URI of the place.stream.livestream record this pin is scoped to. Set when duration is 'streamEnd' (including the unset default); the pin is inactive unless that livestream exists and has not ended. | Format: `at-uri` | +| Name | Type | Req'd | Description | Constraints | +| --------------- | -------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `pinnedMessage` | `string` | ✅ | AT-URI of the pinned chat message. | Format: `at-uri` | +| `pinnedBy` | `string` | ❌ | DID of the user who pinned the message. | Format: `did` | +| `createdAt` | `string` | ✅ | When this pin was created. | Format: `datetime` | +| `expiresAt` | `string` | ❌ | Optional expiration time. If 'livestream' is not set, the pin is considered inactive after this time. | Format: `datetime` | +| `livestream` | `string` | ❌ | AT-URI of the place.stream.livestream record this pin is scoped to. If set, the pin is active only while that livestream exists and has not ended, and takes precedence over 'expiresAt'. If neither 'livestream' nor 'expiresAt' is set, the pin stays active until manually unpinned. | Format: `at-uri` | --- @@ -63,17 +62,12 @@ Record pinning a chat message for prominent display. "expiresAt": { "type": "string", "format": "datetime", - "description": "Optional expiration time. If set, the pin is considered inactive after this time." - }, - "duration": { - "type": "string", - "knownValues": ["streamEnd", "forever"], - "description": "How long this pin lasts when expiresAt is not set. 'streamEnd' (the default when unset) keeps the pin active until the livestream referenced by 'livestream' ends. 'forever' keeps the pin active across streams until manually unpinned." + "description": "Optional expiration time. If 'livestream' is not set, the pin is considered inactive after this time." }, "livestream": { "type": "string", "format": "at-uri", - "description": "AT-URI of the place.stream.livestream record this pin is scoped to. Set when duration is 'streamEnd' (including the unset default); the pin is inactive unless that livestream exists and has not ended." + "description": "AT-URI of the place.stream.livestream record this pin is scoped to. If set, the pin is active only while that livestream exists and has not ended, and takes precedence over 'expiresAt'. If neither 'livestream' nor 'expiresAt' is set, the pin stays active until manually unpinned." } } } diff --git a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createpin.md b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createpin.md index 9a01bc38..aa954cb7 100644 --- a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createpin.md +++ b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createpin.md @@ -24,13 +24,12 @@ Pin a chat message on behalf of a streamer. Requires 'message.pin' permission. C **Schema Type:** `object` -| Name | Type | Req'd | Description | Constraints | -| ------------ | -------- | ----- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` | -| `messageUri` | `string` | ✅ | The AT-URI of the chat message to pin. | Format: `at-uri` | -| `expiresAt` | `string` | ❌ | Optional expiration time for this pin. | Format: `datetime` | -| `duration` | `string` | ❌ | Pin duration, passed through to the pinned record. Defaults to 'streamEnd' when unset. | Known Values: `streamEnd`, `forever` | -| `livestream` | `string` | ❌ | AT-URI of the livestream this pin is scoped to, passed through to the pinned record. Required for 'streamEnd' pins to be active. | Format: `at-uri` | +| Name | Type | Req'd | Description | Constraints | +| ------------ | -------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` | +| `messageUri` | `string` | ✅ | The AT-URI of the chat message to pin. | Format: `at-uri` | +| `expiresAt` | `string` | ❌ | Optional expiration time for this pin. Ignored when 'livestream' is set. | Format: `datetime` | +| `livestream` | `string` | ❌ | AT-URI of the livestream this pin is scoped to, passed through to the pinned record. If set, the pin stays active until that livestream ends. If neither 'livestream' nor 'expiresAt' is set, the pin stays active until manually unpinned. | Format: `at-uri` | **Output:** @@ -81,17 +80,12 @@ Pin a chat message on behalf of a streamer. Requires 'message.pin' permission. C "expiresAt": { "type": "string", "format": "datetime", - "description": "Optional expiration time for this pin." - }, - "duration": { - "type": "string", - "knownValues": ["streamEnd", "forever"], - "description": "Pin duration, passed through to the pinned record. Defaults to 'streamEnd' when unset." + "description": "Optional expiration time for this pin. Ignored when 'livestream' is set." }, "livestream": { "type": "string", "format": "at-uri", - "description": "AT-URI of the livestream this pin is scoped to, passed through to the pinned record. Required for 'streamEnd' pins to be active." + "description": "AT-URI of the livestream this pin is scoped to, passed through to the pinned record. If set, the pin stays active until that livestream ends. If neither 'livestream' nor 'expiresAt' is set, the pin stays active until manually unpinned." } } } diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index f70f7686..29d396bf 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -2126,16 +2126,12 @@ }, "expiresAt": { "type": "string", - "description": "Optional expiration time for this pin.", + "description": "Optional expiration time for this pin. Ignored when 'livestream' is set.", "format": "date-time" }, - "duration": { - "type": "string", - "description": "Pin duration, passed through to the pinned record. Defaults to 'streamEnd' when unset." - }, "livestream": { "type": "string", - "description": "AT-URI of the livestream this pin is scoped to, passed through to the pinned record. Required for 'streamEnd' pins to be active.", + "description": "AT-URI of the livestream this pin is scoped to, passed through to the pinned record. If set, the pin stays active until that livestream ends. If neither 'livestream' nor 'expiresAt' is set, the pin stays active until manually unpinned.", "format": "uri" } }, diff --git a/lexicons/place/stream/chat/pinnedRecord.json b/lexicons/place/stream/chat/pinnedRecord.json index f46b2d1b..de5741f2 100644 --- a/lexicons/place/stream/chat/pinnedRecord.json +++ b/lexicons/place/stream/chat/pinnedRecord.json @@ -28,17 +28,12 @@ "expiresAt": { "type": "string", "format": "datetime", - "description": "Optional expiration time. If set, the pin is considered inactive after this time." - }, - "duration": { - "type": "string", - "knownValues": ["streamEnd", "forever"], - "description": "How long this pin lasts when expiresAt is not set. 'streamEnd' (the default when unset) keeps the pin active until the livestream referenced by 'livestream' ends. 'forever' keeps the pin active across streams until manually unpinned." + "description": "Optional expiration time. If 'livestream' is not set, the pin is considered inactive after this time." }, "livestream": { "type": "string", "format": "at-uri", - "description": "AT-URI of the place.stream.livestream record this pin is scoped to. Set when duration is 'streamEnd' (including the unset default); the pin is inactive unless that livestream exists and has not ended." + "description": "AT-URI of the place.stream.livestream record this pin is scoped to. If set, the pin is active only while that livestream exists and has not ended, and takes precedence over 'expiresAt'. If neither 'livestream' nor 'expiresAt' is set, the pin stays active until manually unpinned." } } } diff --git a/lexicons/place/stream/moderation/createPin.json b/lexicons/place/stream/moderation/createPin.json index 45511859..af03421b 100644 --- a/lexicons/place/stream/moderation/createPin.json +++ b/lexicons/place/stream/moderation/createPin.json @@ -24,17 +24,12 @@ "expiresAt": { "type": "string", "format": "datetime", - "description": "Optional expiration time for this pin." - }, - "duration": { - "type": "string", - "knownValues": ["streamEnd", "forever"], - "description": "Pin duration, passed through to the pinned record. Defaults to 'streamEnd' when unset." + "description": "Optional expiration time for this pin. Ignored when 'livestream' is set." }, "livestream": { "type": "string", "format": "at-uri", - "description": "AT-URI of the livestream this pin is scoped to, passed through to the pinned record. Required for 'streamEnd' pins to be active." + "description": "AT-URI of the livestream this pin is scoped to, passed through to the pinned record. If set, the pin stays active until that livestream ends. If neither 'livestream' nor 'expiresAt' is set, the pin stays active until manually unpinned." } } } diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index 4a0a2b66..a5d11c40 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -286,9 +286,6 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD Repo: repo, ExpiresAt: expiresAt, } - if rec.Duration != nil { - pin.Duration = *rec.Duration - } if rec.Livestream != nil { pin.LivestreamURI = *rec.Livestream } diff --git a/pkg/model/pinned_record.go b/pkg/model/pinned_record.go index e2998e19..9dd5c97f 100644 --- a/pkg/model/pinned_record.go +++ b/pkg/model/pinned_record.go @@ -22,7 +22,6 @@ type PinnedRecord struct { IndexedAt *time.Time `gorm:"column:indexed_at" json:"indexedAt"` ExpiresAt *time.Time `gorm:"column:expires_at" json:"expiresAt"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` - Duration string `gorm:"column:duration" json:"duration,omitempty"` LivestreamURI string `gorm:"column:livestream_uri" json:"livestreamURI,omitempty"` } @@ -36,9 +35,6 @@ func (p *PinnedRecord) ToStreamplacePinnedRecord() (placestream.ChatPinnedRecord s := p.ExpiresAt.UTC().Format(util.ISO8601) rec.ExpiresAt = &s } - if p.Duration != "" { - rec.Duration = &p.Duration - } if p.LivestreamURI != "" { rec.Livestream = &p.LivestreamURI } @@ -55,9 +51,6 @@ func (p *PinnedRecord) ToStreamplacePinnedRecordView() (placestream.ChatDefs_Pin s := p.ExpiresAt.UTC().Format(util.ISO8601) pr.ExpiresAt = &s } - if p.Duration != "" { - pr.Duration = &p.Duration - } if p.LivestreamURI != "" { pr.Livestream = &p.LivestreamURI } @@ -118,32 +111,32 @@ func (m *DBModel) GetActivePinnedRecord(ctx context.Context, streamerDID string) } // isPinActive checks whether a pin should currently be shown. The most recent -// pin record for a streamer is authoritative: if it is expired, or scoped to -// a livestream (duration="streamEnd" or unset) whose stream has ended, the -// streamer has no active pin — older records are not resurrected. +// pin record for a streamer is authoritative: if it is scoped to a livestream +// whose stream has ended, or expires (expiresAt in the past with no +// livestream), the streamer has no active pin — older records are not +// resurrected. A pin with neither livestream nor expiresAt is permanent. func (m *DBModel) isPinActive(pin *PinnedRecord) (bool, error) { - if pin.ExpiresAt != nil && pin.ExpiresAt.Before(time.Now()) { - return false, nil - } - if pin.Duration == "forever" { - return true, nil - } - // Legacy pins with no livestream scope are always active. - if pin.LivestreamURI == "" { + // A livestream-scoped pin is active only while the referenced stream + // exists and has not ended; livestream takes precedence over expiresAt. + if pin.LivestreamURI != "" { + ls, err := m.GetLivestream(pin.LivestreamURI) + if err != nil { + return false, err + } + if ls == nil { + return false, nil + } + if ls.Livestream != nil { + var rec placestream.Livestream + if err := glex.DecodeCBOR(*ls.Livestream, &rec); err == nil && rec.EndedAt != nil { + return false, nil + } + } return true, nil } - ls, err := m.GetLivestream(pin.LivestreamURI) - if err != nil { - return false, err - } - if ls == nil { + // Timed pins expire; pins with neither livestream nor expiresAt are permanent. + if pin.ExpiresAt != nil && pin.ExpiresAt.Before(time.Now()) { return false, nil } - if ls.Livestream != nil { - var rec placestream.Livestream - if err := glex.DecodeCBOR(*ls.Livestream, &rec); err == nil && rec.EndedAt != nil { - return false, nil - } - } return true, nil } diff --git a/pkg/model/pinned_record_test.go b/pkg/model/pinned_record_test.go index 17255935..2c5ce472 100644 --- a/pkg/model/pinned_record_test.go +++ b/pkg/model/pinned_record_test.go @@ -36,7 +36,7 @@ func putLivestream(t *testing.T, m Model, uri string, endedAt *string) { func ptrTo[T any](v T) *T { return &v } // putPinnedRecord inserts a PinnedRecord directly for testing. -func putPinnedRecord(t *testing.T, m Model, uri, streamerDID string, createdAt time.Time, duration, livestreamURI string, expiresAt *time.Time) { +func putPinnedRecord(t *testing.T, m Model, uri, streamerDID string, createdAt time.Time, livestreamURI string, expiresAt *time.Time) { t.Helper() pin := &PinnedRecord{ Uri: uri, @@ -45,7 +45,6 @@ func putPinnedRecord(t *testing.T, m Model, uri, streamerDID string, createdAt t PinnedMessage: "at://did:plc:user/place.stream.chat.message/msg1", PinnedBy: streamerDID, CreatedAt: createdAt, - Duration: duration, LivestreamURI: livestreamURI, ExpiresAt: expiresAt, } @@ -58,28 +57,31 @@ func TestGetActivePinnedRecord_LegacyPinAlwaysActive(t *testing.T) { ctx := context.Background() const streamer = "did:plc:streamer" - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "", "", nil) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "", nil) pin, err := m.GetActivePinnedRecord(ctx, streamer) require.NoError(t, err) - require.NotNil(t, pin, "legacy pin with no duration/livestream should be active") + require.NotNil(t, pin, "pin with neither livestream nor expiresAt should be active (permanent)") require.Equal(t, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", pin.Uri) } -func TestGetActivePinnedRecord_ForeverPinAlwaysActive(t *testing.T) { +// TestGetActivePinnedRecord_PermanentPinIgnoresStreamEnd covers the product +// scenario: a pin with neither livestream nor expiresAt is permanent, so an +// ended livestream belonging to the same streamer must not deactivate it. +func TestGetActivePinnedRecord_PermanentPinIgnoresStreamEnd(t *testing.T) { m, err := MakeDB(":memory:") require.NoError(t, err) ctx := context.Background() const streamer = "did:plc:streamer" const lsURI = "at://did:plc:streamer/place.stream.livestream/ls1" - // Create an ended livestream — the forever pin should still be active. + // Create an ended livestream — the permanent pin should still be active. putLivestream(t, m, lsURI, ptrTo(time.Now().UTC().Format(time.RFC3339))) - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "forever", lsURI, nil) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "", nil) pin, err := m.GetActivePinnedRecord(ctx, streamer) require.NoError(t, err) - require.NotNil(t, pin, "forever pin should be active even if livestream ended") + require.NotNil(t, pin, "permanent pin should be active even if a livestream ended") } func TestGetActivePinnedRecord_StreamEndWithActiveLivestream(t *testing.T) { @@ -90,11 +92,11 @@ func TestGetActivePinnedRecord_StreamEndWithActiveLivestream(t *testing.T) { const streamer = "did:plc:streamer" const lsURI = "at://did:plc:streamer/place.stream.livestream/ls1" putLivestream(t, m, lsURI, nil) // active stream - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "", lsURI, nil) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), lsURI, nil) pin, err := m.GetActivePinnedRecord(ctx, streamer) require.NoError(t, err) - require.NotNil(t, pin, "streamEnd pin with active livestream should be active") + require.NotNil(t, pin, "livestream-scoped pin with active livestream should be active") } func TestGetActivePinnedRecord_StreamEndWithEndedLivestream(t *testing.T) { @@ -105,11 +107,11 @@ func TestGetActivePinnedRecord_StreamEndWithEndedLivestream(t *testing.T) { const streamer = "did:plc:streamer" const lsURI = "at://did:plc:streamer/place.stream.livestream/ls1" putLivestream(t, m, lsURI, ptrTo(time.Now().UTC().Format(time.RFC3339))) // ended - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "", lsURI, nil) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), lsURI, nil) pin, err := m.GetActivePinnedRecord(ctx, streamer) require.NoError(t, err) - require.Nil(t, pin, "streamEnd pin with ended livestream should be inactive") + require.Nil(t, pin, "livestream-scoped pin with ended livestream should be inactive") } func TestGetActivePinnedRecord_StreamEndWithMissingLivestream(t *testing.T) { @@ -118,11 +120,11 @@ func TestGetActivePinnedRecord_StreamEndWithMissingLivestream(t *testing.T) { ctx := context.Background() const streamer = "did:plc:streamer" - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "", "at://did:plc:streamer/place.stream.livestream/nonexistent", nil) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "at://did:plc:streamer/place.stream.livestream/nonexistent", nil) pin, err := m.GetActivePinnedRecord(ctx, streamer) require.NoError(t, err) - require.Nil(t, pin, "streamEnd pin with missing livestream should be inactive") + require.Nil(t, pin, "livestream-scoped pin with missing livestream should be inactive") } func TestGetActivePinnedRecord_NewestSupersedesOlder(t *testing.T) { @@ -136,21 +138,21 @@ func TestGetActivePinnedRecord_NewestSupersedesOlder(t *testing.T) { // ls1 has ended. putLivestream(t, m, ls1URI, ptrTo(time.Now().UTC().Format(time.RFC3339))) - // Pin for ended stream (newest) supersedes the older forever pin. - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now().Add(-1*time.Minute), "forever", "", nil) - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p2", streamer, time.Now(), "", ls1URI, nil) + // Pin for ended stream (newest) supersedes the older permanent pin. + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now().Add(-1*time.Minute), "", nil) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p2", streamer, time.Now(), ls1URI, nil) pin, err := m.GetActivePinnedRecord(ctx, streamer) require.NoError(t, err) require.Nil(t, pin, "most recent pin's stream ended, so no older pin should be resurrected") } -// TestGetActivePinnedRecord_PinForeverThenPinStream_StreamEnds covers the -// product scenario: pin a message forever, then pin a message scoped to the -// current stream. When the stream ends, the stream pin goes inactive and the -// forever pin must NOT come back — the most recent pin record is +// TestGetActivePinnedRecord_PermanentThenStreamPin_StreamEnds covers the +// product scenario: pin a message permanently, then pin a message scoped to +// the current stream. When the stream ends, the stream pin goes inactive and +// the permanent pin must NOT come back — the most recent pin record is // authoritative. -func TestGetActivePinnedRecord_PinForeverThenPinStream_StreamEnds(t *testing.T) { +func TestGetActivePinnedRecord_PermanentThenStreamPin_StreamEnds(t *testing.T) { m, err := MakeDB(":memory:") require.NoError(t, err) ctx := context.Background() @@ -159,12 +161,12 @@ func TestGetActivePinnedRecord_PinForeverThenPinStream_StreamEnds(t *testing.T) const lsURI = "at://did:plc:streamer/place.stream.livestream/ls1" putLivestream(t, m, lsURI, ptrTo(time.Now().UTC().Format(time.RFC3339))) // ended - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now().Add(-2*time.Minute), "forever", "", nil) - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p2", streamer, time.Now(), "", lsURI, nil) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now().Add(-2*time.Minute), "", nil) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p2", streamer, time.Now(), lsURI, nil) pin, err := m.GetActivePinnedRecord(ctx, streamer) require.NoError(t, err) - require.Nil(t, pin, "stream pin superseded the forever pin; after stream end there is no pin") + require.Nil(t, pin, "stream pin superseded the permanent pin; after stream end there is no pin") } func TestGetActivePinnedRecord_NewestActiveWins(t *testing.T) { @@ -176,27 +178,27 @@ func TestGetActivePinnedRecord_NewestActiveWins(t *testing.T) { const lsURI = "at://did:plc:streamer/place.stream.livestream/ls1" putLivestream(t, m, lsURI, nil) // active - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now().Add(-2*time.Minute), "forever", "", nil) - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p2", streamer, time.Now(), "", lsURI, nil) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now().Add(-2*time.Minute), "", nil) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p2", streamer, time.Now(), lsURI, nil) pin, err := m.GetActivePinnedRecord(ctx, streamer) require.NoError(t, err) - require.NotNil(t, pin, "most recent pin is active, so it wins over the older forever pin") + require.NotNil(t, pin, "most recent pin is active, so it wins over the older permanent pin") require.Equal(t, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p2", pin.Uri) } -func TestGetActivePinnedRecord_ExpiredPinSkipped(t *testing.T) { +func TestGetActivePinnedRecord_ExpiredTimedPinSkipped(t *testing.T) { m, err := MakeDB(":memory:") require.NoError(t, err) ctx := context.Background() const streamer = "did:plc:streamer" past := time.Now().Add(-1 * time.Hour) - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "forever", "", &past) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "", &past) pin, err := m.GetActivePinnedRecord(ctx, streamer) require.NoError(t, err) - require.Nil(t, pin, "expired pin should not be returned even if duration is forever") + require.Nil(t, pin, "expired timed pin should not be returned") } func TestGetActivePinnedRecord_NewestExpiredSupersedesOlder(t *testing.T) { @@ -207,15 +209,15 @@ func TestGetActivePinnedRecord_NewestExpiredSupersedesOlder(t *testing.T) { const streamer = "did:plc:streamer" past := time.Now().Add(-1 * time.Hour) - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now().Add(-2*time.Minute), "forever", "", nil) - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p2", streamer, time.Now(), "forever", "", &past) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now().Add(-2*time.Minute), "", nil) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p2", streamer, time.Now(), "", &past) pin, err := m.GetActivePinnedRecord(ctx, streamer) require.NoError(t, err) require.Nil(t, pin, "expired newest pin supersedes the older active one") } -func TestGetActivePinnedRecord_DurationAndLivestreamRoundTrip(t *testing.T) { +func TestGetActivePinnedRecord_ExpiresAtAndLivestreamRoundTrip(t *testing.T) { m, err := MakeDB(":memory:") require.NoError(t, err) ctx := context.Background() @@ -223,7 +225,8 @@ func TestGetActivePinnedRecord_DurationAndLivestreamRoundTrip(t *testing.T) { const streamer = "did:plc:streamer" const lsURI = "at://did:plc:streamer/place.stream.livestream/ls1" putLivestream(t, m, lsURI, nil) - putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "forever", lsURI, nil) + future := time.Now().Add(time.Hour) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), lsURI, &future) pin, err := m.GetActivePinnedRecord(ctx, streamer) require.NoError(t, err) @@ -231,8 +234,99 @@ func TestGetActivePinnedRecord_DurationAndLivestreamRoundTrip(t *testing.T) { pr, err := pin.ToStreamplacePinnedRecord() require.NoError(t, err) - require.NotNil(t, pr.Duration) - require.Equal(t, "forever", *pr.Duration) + require.NotNil(t, pr.ExpiresAt) require.NotNil(t, pr.Livestream) require.Equal(t, lsURI, *pr.Livestream) } + +func TestGetActivePinnedRecord_TimedPinActiveBeforeExpiry(t *testing.T) { + m, err := MakeDB(":memory:") + require.NoError(t, err) + ctx := context.Background() + + const streamer = "did:plc:streamer" + future := time.Now().Add(time.Hour) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "", &future) + + pin, err := m.GetActivePinnedRecord(ctx, streamer) + require.NoError(t, err) + require.NotNil(t, pin, "timed pin before expiry should be active") +} + +// TestGetActivePinnedRecord_TimedPinSurvivesStreamEnd locks in the behavior +// change from the duration enum: a timed pin has no livestream scope, so the +// streamer's stream ending must not expire it. +func TestGetActivePinnedRecord_TimedPinSurvivesStreamEnd(t *testing.T) { + m, err := MakeDB(":memory:") + require.NoError(t, err) + ctx := context.Background() + + const streamer = "did:plc:streamer" + const lsURI = "at://did:plc:streamer/place.stream.livestream/ls1" + putLivestream(t, m, lsURI, ptrTo(time.Now().UTC().Format(time.RFC3339))) // ended + future := time.Now().Add(time.Hour) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), "", &future) + + pin, err := m.GetActivePinnedRecord(ctx, streamer) + require.NoError(t, err) + require.NotNil(t, pin, "timed pin is not scoped to a livestream, so stream end must not expire it") +} + +// TestGetActivePinnedRecord_LivestreamBeatsExpiresAt covers the precedence +// chain: a livestream-scoped pin is governed by the livestream even when +// expiresAt has passed. +func TestGetActivePinnedRecord_LivestreamBeatsExpiresAt(t *testing.T) { + m, err := MakeDB(":memory:") + require.NoError(t, err) + ctx := context.Background() + + const streamer = "did:plc:streamer" + const lsURI = "at://did:plc:streamer/place.stream.livestream/ls1" + putLivestream(t, m, lsURI, nil) // active + past := time.Now().Add(-1 * time.Hour) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), lsURI, &past) + + pin, err := m.GetActivePinnedRecord(ctx, streamer) + require.NoError(t, err) + require.NotNil(t, pin, "livestream takes precedence over expiresAt; an active stream keeps the pin") +} + +// TestGetActivePinnedRecord_LivestreamEndedBeatsExpiresAt is the other half +// of the precedence chain: an ended livestream deactivates the pin even when +// expiresAt is in the future. +func TestGetActivePinnedRecord_LivestreamEndedBeatsExpiresAt(t *testing.T) { + m, err := MakeDB(":memory:") + require.NoError(t, err) + ctx := context.Background() + + const streamer = "did:plc:streamer" + const lsURI = "at://did:plc:streamer/place.stream.livestream/ls1" + putLivestream(t, m, lsURI, ptrTo(time.Now().UTC().Format(time.RFC3339))) // ended + future := time.Now().Add(time.Hour) + putPinnedRecord(t, m, "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1", streamer, time.Now(), lsURI, &future) + + pin, err := m.GetActivePinnedRecord(ctx, streamer) + require.NoError(t, err) + require.Nil(t, pin, "livestream takes precedence over expiresAt; an ended stream deactivates the pin") +} + +// TestGetActivePinnedRecord_LegacyDurationColumnIgnored simulates rows written +// before the duration field was removed from the lexicon: they carry a +// duration column value that must not affect the livestream/expiresAt logic. +func TestGetActivePinnedRecord_LegacyDurationColumnIgnored(t *testing.T) { + m, err := MakeDB(":memory:") + require.NoError(t, err) + ctx := context.Background() + + const streamer = "did:plc:streamer" + const pinURI = "at://did:plc:streamer/place.stream.chat.pinnedRecord/p1" + // Fresh DBs no longer create the column; add it to simulate a pre-change DB. + db := m.(*DBModel).DB + require.NoError(t, db.Exec("ALTER TABLE pinned_records ADD COLUMN duration TEXT").Error) + putPinnedRecord(t, m, pinURI, streamer, time.Now(), "", nil) + require.NoError(t, db.Exec("UPDATE pinned_records SET duration = 'forever' WHERE uri = ?", pinURI).Error) + + pin, err := m.GetActivePinnedRecord(ctx, streamer) + require.NoError(t, err) + require.NotNil(t, pin, "legacy duration value is ignored; a pin with no livestream/expiresAt is permanent") +} diff --git a/pkg/placestream/chatpinnedrecord.go b/pkg/placestream/chatpinnedrecord.go index 55c473c2..9636d4a4 100644 --- a/pkg/placestream/chatpinnedrecord.go +++ b/pkg/placestream/chatpinnedrecord.go @@ -21,11 +21,9 @@ type ChatPinnedRecord struct { LexiconTypeID string `json:"$type,omitempty"` // createdAt: When this pin was created. CreatedAt string `json:"createdAt"` - // duration: How long this pin lasts when expiresAt is not set. 'streamEnd' (the default when unset) keeps the pin active until the livestream referenced by 'livestream' ends. 'forever' keeps the pin active across streams until manually unpinned. - Duration *string `json:"duration,omitempty"` - // expiresAt: Optional expiration time. If set, the pin is considered inactive after this time. + // expiresAt: Optional expiration time. If 'livestream' is not set, the pin is considered inactive after this time. ExpiresAt *string `json:"expiresAt,omitempty"` - // livestream: AT-URI of the place.stream.livestream record this pin is scoped to. Set when duration is 'streamEnd' (including the unset default); the pin is inactive unless that livestream exists and has not ended. + // livestream: AT-URI of the place.stream.livestream record this pin is scoped to. If set, the pin is active only while that livestream exists and has not ended, and takes precedence over 'expiresAt'. If neither 'livestream' nor 'expiresAt' is set, the pin stays active until manually unpinned. Livestream *string `json:"livestream,omitempty"` // pinnedBy: DID of the user who pinned the message. PinnedBy *string `json:"pinnedBy,omitempty"` diff --git a/pkg/placestream/moderationcreatepin.go b/pkg/placestream/moderationcreatepin.go index a56aa3ce..d1ad16ed 100644 --- a/pkg/placestream/moderationcreatepin.go +++ b/pkg/placestream/moderationcreatepin.go @@ -14,11 +14,9 @@ import ( type ModerationCreatePin_Input struct { LexiconTypeID string `json:"$type,omitempty"` - // duration: Pin duration, passed through to the pinned record. Defaults to 'streamEnd' when unset. - Duration *string `json:"duration,omitempty"` - // expiresAt: Optional expiration time for this pin. + // expiresAt: Optional expiration time for this pin. Ignored when 'livestream' is set. ExpiresAt *string `json:"expiresAt,omitempty"` - // livestream: AT-URI of the livestream this pin is scoped to, passed through to the pinned record. Required for 'streamEnd' pins to be active. + // livestream: AT-URI of the livestream this pin is scoped to, passed through to the pinned record. If set, the pin stays active until that livestream ends. If neither 'livestream' nor 'expiresAt' is set, the pin stays active until manually unpinned. Livestream *string `json:"livestream,omitempty"` // messageUri: The AT-URI of the chat message to pin. MessageUri string `json:"messageUri"` diff --git a/pkg/spxrpc/place_stream_moderation.go b/pkg/spxrpc/place_stream_moderation.go index 3f9295de..21a5e068 100644 --- a/pkg/spxrpc/place_stream_moderation.go +++ b/pkg/spxrpc/place_stream_moderation.go @@ -376,7 +376,6 @@ func (s *Server) handlePlaceStreamModerationCreatePin(ctx context.Context, input PinnedMessage: input.MessageUri, CreatedAt: time.Now().UTC().Format(time.RFC3339), ExpiresAt: input.ExpiresAt, - Duration: input.Duration, Livestream: input.Livestream, }